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>
This commit is contained in:
Sebastian Mayer 2026-05-27 22:15:16 +02:00
commit 2436dd9cac
158 changed files with 68424 additions and 0 deletions

95
.claude/rules.md Normal file
View file

@ -0,0 +1,95 @@
# Claude Project Rules - Hellth Hub
## 1. Type Safety
- TypeScript strict mode
- Kein `any`
- Keine unsicheren Type Assertions (`as X`)
- Drizzle-Query-Builder-Typen nutzen statt eigene Interfaces dupliziert beschreiben
## 2. Trennung der Verantwortlichkeiten
```
Business-Logik → packages/api (tRPC Router, Coach, Domain-Helper)
DB-Logik → packages/db (Drizzle-Schema, Migrationen)
Apps → apps/admin (nur UI + dünner Glue zur API)
```
Die Admin-App enthält keine Business-Logik. Wenn Berechnung in beiden Seiten gebraucht wird (z. B. Nährwerte), gehört der Kanon in `packages/api/src/lib/`; das Frontend bekommt ihn über tRPC oder dupliziert nur die UI-nahe Anzeige-Variante in `apps/admin/lib/hellth-data.ts`.
## 3. Domäne
Hellth Hub ist eine Single-User-Health-App pro Benutzer-Account. Datenmodell siehe `packages/db/src/schema/health.ts` (Foods, Recipes, MealLogs, ShoppingLists, Stores, ActivityLogs, Wearables, ClubActivities, Coach …).
Keine Naturfreunde-Begriffe wiederbeleben (News, Events, Fachgruppen, Mandanten, Häuser, Buchungen, `tenantId`, `verband_admin`, `haus_admin`, Mitgliedsausweise, Google-Wallet).
## 4. Rollen
- `user` (default) - normaler Benutzer
- `admin` - Plattform-Admin (z. B. `admin@onl1.eu`)
- Admins haben Pflicht-2FA (siehe `enforceTwoFactorForPrivilegedUsers` in `packages/api/src/trpc/init.ts`)
Rechte immer **serverseitig** prüfen (tRPC-Procedure-Auswahl: `protectedProcedure` für eingeloggte User, `platformProcedure` für Admins).
## 5. Performance
- Keine N+1-Queries (Drizzle `.with()` / Joins / `inArray` für Batch-Loads nutzen)
- Listen-Endpoints mit Suche: Filterung auf DB-Seite (`ilike`), nicht in-Memory
- Index-Pfade respektieren (siehe Index-Definitionen in `schema/health.ts`)
## 6. Sicherheit
- Auth-Pflicht für alle Routen außer Login / Health-Check
- Keine Secrets im Repo - nur `.env` (in `.gitignore`)
- 2FA-Recovery nur für Admins (`platformProcedure`)
- Bei `twoFactorRecoveryRequired = true` muss 2FA-Neueinrichtung erzwungen werden
- Login auf `/login/otp` muss sowohl TOTP-Code als auch Backup-Code unterstützen
- Better-Auth-Konfiguration (`packages/api/src/lib/auth.ts`) ist Single Source of Truth für Auth-Optionen
- CSP / Security-Headers in `apps/admin/next.config.ts` und `packages/api/src/app.ts` synchron halten
### User-Security-Felder (aktiv)
```
twoFactorEnabled
twoFactorRecoveryRequired
twoFactorRecoveryStartedAt
twoFactorRecoveryStartedBy
```
## 7. UI-Regeln
- Mobile-first - Hellth Hub wird primär am Handy benutzt
- Klar, modern, funktional - keine visuellen Spielereien
- Bottom-Navigation auf Mobile, Sidebar auf Desktop (`apps/admin/app/_components/`)
- Neue Formulare über `FormField` und `inputClassName` aus `apps/admin/app/_components/form-field.tsx`
- Deutsche UI: echte Umlaute (`ä`, `ö`, `ü`, `ß`)
- UI-Inspiration siehe `docs/inspiration/bitepal/`
## 8. DB-Änderungen
- Schema-Änderung, Migration, API-Endpoint und UI immer zusammen denken
- `pnpm --filter @hellth/db db:generate` für Migrationen
- Migrationen nicht nachträglich umbenennen oder löschen (Drizzle tracked sie per Datei + Hash)
- Neue Tabellen brauchen passende Indizes für die Haupt-Queries
## 9. Tests
Mindestens absichern:
- Auth- und 2FA-Pfade
- Nährwert- / Kalorien- / Wochenbudget-Berechnung
- Hellfireclub-Punkte-Logik
Vor PR / größerer Änderung lokal grün:
```powershell
pnpm --filter @hellth/api test
pnpm --filter admin test
pnpm exec tsc --noEmit --project packages/api/tsconfig.json
$env:NEXT_PUBLIC_API_URL='http://localhost:3002'; pnpm --filter admin build
```
## 10. Arbeitsweise mit Claude
- Bestehende User-Änderungen nicht zurücksetzen
- Keine destruktiven Git-Befehle ohne ausdrückliche Anweisung
- Cleanup an Altlasten nur nach expliziter Freigabe (siehe `claude-info.md` Abschnitt "Bekannte Altlasten")

14
.claude/settings.json Normal file
View file

@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(wc:*)",
"Bash(find:*)",
"Bash(pnpm --filter @hellth/db db:push:*)",
"Bash(pnpm --filter @hellth/db db:generate:*)",
"Bash(pnpm --filter @hellth/db db:migrate:*)",
"Bash(pnpm --filter @hellth/api test:*)",
"Bash(pnpm --filter admin test:*)",
"Bash(pnpm exec tsc:*)"
]
}
}

29
.env.example Normal file
View file

@ -0,0 +1,29 @@
# Datenbank
DATABASE_URL=postgresql://hellth:hellth_dev@localhost:5432/hellth_hub
# Better Auth, in Produktion durch starkes Secret ersetzen
BETTER_AUTH_SECRET=change-me-in-production-use-openssl-rand-base64-32
BETTER_AUTH_BASE_URL=http://localhost:3002
PASSKEY_RP_NAME=Hellth Hub
# API
API_PORT=3002
TRUSTED_ORIGINS=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:3002
TRUST_PROXY_FORWARD_HEADERS=false
DATABASE_REQUIRE_SSL=false
ENABLE_DB_MIGRATIONS=false
# Plattform-Admin, kommasepariert
PLATFORM_ADMIN_EMAILS=admin@domain.tld
NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS=admin@domain.tld
# Initialer Admin-User
ADMIN_EMAIL=admin@domain.tld
ADMIN_INITIAL_PASSWORD=please-set-a-strong-password-with-16-plus-chars
# Wearables: Fitbit OAuth, optional
FITBIT_CLIENT_ID=
FITBIT_CLIENT_SECRET=
FITBIT_REDIRECT_URI=http://localhost:3002/api/wearables/fitbit/callback
WEARABLE_TOKEN_ENCRYPTION_KEY=change-me-32-byte-base64-key

47
.gitignore vendored Normal file
View file

@ -0,0 +1,47 @@
# Dependencies
node_modules/
.pnpm-store/
# Build/Cache
.next/
out/
dist/
build/
.turbo/
.cache/
coverage/
# Logs
*.log
.logs/
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# Env/Secrets
.env
.env.*
!.env.example
# OS/Editor
.DS_Store
Thumbs.db
.vscode/
.idea/
# Runtime/Uploads
uploads/
tmp/
temp/
# Python (for scripts/)
__pycache__/
*.pyc
.pytest_cache/
# Local machine-specific agent settings
.claude/settings.local.json
# Project-specific
credentials.txt

4
.npmrc Normal file
View file

@ -0,0 +1,4 @@
onlyBuiltDependencies[]=esbuild
onlyBuiltDependencies[]=sharp
onlyBuiltDependencies[]=unrs-resolver
shamefully-hoist=true

24
AGENTS.md Normal file
View file

@ -0,0 +1,24 @@
# Agent-Regeln - Hellth Hub
Regeln für AI-Agents (Codex, Claude, weitere Assistenten) in diesem Repository.
## Ziel
- Einheitliche Arbeitsweise für AI-gestützte Änderungen.
- Klare Priorität bei möglichen Konflikten zwischen Markdown-Quellen.
- Hellth Hub bleibt fachlich eine Health- und Ernährungstracking-App.
## Lesereihenfolge für Agents
1. `AGENTS.md`
2. `docs/ai/quick-reference.md`
3. `docs/INDEX.md`
4. Fachliche Detaildokumente nach Bedarf
## Grundregeln
- Umlaute immer korrekt schreiben.
- Keine alten Altprojekt-, Tenant- oder Community-Portal-Begriffe in UI, API oder Doku einführen.
- Benutzerbezogene Einstellungen bleiben pro User gespeichert.
- Auth, 2FA, Admin-UI und Docker-Setup bleiben lauffähig.
- Keine Secrets in Markdown-Dateien speichern.

11
CLAUDE.md Normal file
View file

@ -0,0 +1,11 @@
# Claude Working Guidelines - Hellth Hub
Hellth Hub ist eine mobile-first Health- und Ernährungstracking-App für den Home-Lab-Betrieb.
Wichtig:
- Umlaute immer korrekt schreiben.
- Keine alten Altprojekt-/Tenant-/Community-Begriffe in neue UI oder Doku übernehmen.
- Einstellungen und Health-Daten sind benutzerbezogen, nicht global.
- Bestehende Architektur nutzen: `apps/admin`, `packages/api`, `packages/db`.
- Änderungen mit Tests absichern, besonders bei Auth, Health-Daten, Wochenbudget und Einkaufslisten.

40
README.md Normal file
View file

@ -0,0 +1,40 @@
# Hellth Hub
Mobile-first Web-App für persönliches Health- und Ernährungstracking im Home-Lab.
## Lokaler Start
```bash
docker compose -f docker/docker-compose.yml up -d db
pnpm --filter @hellth/db db:migrate
pnpm --filter @hellth/api seed:admin
pnpm --filter @hellth/api seed:health
pnpm --filter @hellth/api dev
pnpm --filter admin dev
```
Danach:
- Admin-UI: http://localhost:3001
- API: http://localhost:3002
- Health Check: http://localhost:3002/api/health
## Aktuelle Module
- Auth, Login, 2FA, Passkeys und Benutzerverwaltung
- Tagesansicht mit Essen, Trinken, Gewicht, Aktivitäten und Coach-Hinweisen
- Lebensmittelkatalog mit Nährwerten, Portionen, Favoriten und OCR-Vorbereitung
- Rezepte mit flexiblen Mengenangaben, Bewertungen und Meal-Prep-Bezug
- Essensplanung mit Tages- und Wochenlogik
- Wochenbudget für Kalorien inklusive Sportbonus
- Einkaufsliste mit Märkten, Abteilungen und Laufweg-Sortierung
- Ziele, Onboarding und persönliche Einstellungen pro Benutzer
- Erinnerungen, Aktivitätspunkte, Hellfireclub und Wearable-Vorbereitung
## Verifikation
```bash
pnpm --filter @hellth/api test -- --test-reporter=spec
pnpm --filter admin test
NEXT_PUBLIC_API_URL=http://localhost:3002 pnpm --filter admin build
```

View file

@ -0,0 +1,371 @@
"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>
);
}

View file

@ -0,0 +1,84 @@
"use client";
import Link from "next/link";
import { trpc } from "@/lib/trpc";
function statusLabel(status: string) {
if (status === "accepted") return "übernommen";
if (status === "rejected") return "abgelehnt";
if (status === "pending") return "in Prüfung";
return status;
}
export default function AktivitaetsfeedPage() {
const utils = trpc.useUtils();
const feedQuery = trpc.health.adminActivityFeed.useQuery();
const reviewStoreChange = trpc.health.reviewStoreChangeRequest.useMutation({
onSuccess: async () => {
await utils.health.adminActivityFeed.invalidate();
await utils.health.listStores.invalidate();
},
});
const items = feedQuery.data ?? [];
return (
<div className="mx-auto max-w-5xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Admin</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Activity Feed</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Eingereichte Rezeptvorschläge, Marktänderungen und weitere prüfpflichtige Aktionen an einem Ort.
</p>
</header>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="space-y-3">
{items.map((item) => (
<div key={`${item.type}-${item.id}`} className="rounded-3xl bg-gray-50 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-black uppercase text-[#0F766E]">
{item.type === "recipe_submission" ? "Rezeptvorschlag" : "Marktänderung"}
</p>
<h2 className="mt-1 text-lg font-black text-gray-950">{item.title}</h2>
<p className="mt-1 text-sm font-bold text-gray-400">{item.actorName}{item.actorEmail ? ` · ${item.actorEmail}` : ""}</p>
<p className="mt-1 text-sm font-semibold text-gray-500">{item.detail}</p>
</div>
<div className="text-right">
<span className={`rounded-full px-3 py-1 text-xs font-black ${
item.status === "accepted"
? "bg-emerald-100 text-emerald-700"
: item.status === "rejected"
? "bg-red-100 text-red-700"
: "bg-amber-100 text-amber-700"
}`}>
{statusLabel(item.status)}
</span>
<p className="mt-2 text-xs font-bold text-gray-400">{new Date(item.createdAt).toLocaleString("de-DE")}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Link href={item.href} className="rounded-full bg-white px-4 py-2 text-xs font-black text-gray-700">
Öffnen
</Link>
{item.type === "store_change" && item.status === "pending" ? (
<>
<button type="button" onClick={() => reviewStoreChange.mutate({ id: item.id, action: "accept" })} className="rounded-full bg-[#0F766E] px-4 py-2 text-xs font-black text-white">
Übernehmen
</button>
<button type="button" onClick={() => reviewStoreChange.mutate({ id: item.id, action: "reject" })} className="rounded-full bg-red-50 px-4 py-2 text-xs font-black text-red-700">
Ablehnen
</button>
</>
) : null}
</div>
</div>
))}
{!items.length && !feedQuery.isLoading ? (
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Aktuell gibt es keine Einträge.</p>
) : null}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,187 @@
"use client";
import { PlusIcon, ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { trpc } from "@/lib/trpc";
type Role = "user" | "admin";
export default function BenutzerPage() {
const utils = trpc.useUtils();
const usersQuery = trpc.users.list.useQuery();
const createUser = trpc.users.create.useMutation({
onSuccess: () => {
setCreateForm({ name: "", email: "", password: "", globalRole: "user" });
return utils.users.list.invalidate();
},
});
const updateUser = trpc.users.update.useMutation({
onSuccess: () => utils.users.list.invalidate(),
});
const startRecovery = trpc.users.startTwoFactorRecovery.useMutation({
onSuccess: () => utils.users.list.invalidate(),
});
const [search, setSearch] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [createForm, setCreateForm] = useState({
name: "",
email: "",
password: "",
globalRole: "user" as Role,
});
const users = usersQuery.data ?? [];
const selectedUser = users.find((user) => user.id === selectedUserId) ?? users[0] ?? null;
const filteredUsers = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return users;
return users.filter((user) =>
[user.name, user.email, user.globalRole].filter(Boolean).some((value) => value!.toLowerCase().includes(needle)),
);
}, [search, users]);
function submitCreate() {
if (!createForm.name.trim() || !createForm.email.trim() || createForm.password.length < 16) return;
createUser.mutate({
name: createForm.name.trim(),
email: createForm.email.trim(),
password: createForm.password,
globalRole: createForm.globalRole,
});
}
function updateRole(userId: string, role: Role) {
const user = users.find((item) => item.id === userId);
if (!user) return;
updateUser.mutate({
userId,
name: user.name ?? user.email,
email: user.email,
globalRole: role,
image: user.image ?? null,
});
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Admin</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Benutzerverwaltung</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Benutzer, Rollen und 2FA-Wiederherstellung für Hellth Hub.
</p>
</header>
<section className="grid gap-4 xl:grid-cols-[1fr_0.75fr]">
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Name, E-Mail oder Rolle suchen"
className="w-full rounded-full border border-gray-200 px-4 py-3 text-sm font-semibold outline-none focus:border-[#0F766E]"
/>
<div className="mt-5 overflow-hidden rounded-3xl border border-gray-100">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-xs uppercase text-gray-400">
<tr>
<th className="px-4 py-3">Benutzer</th>
<th className="px-4 py-3">Rolle</th>
<th className="px-4 py-3">2FA</th>
<th className="px-4 py-3">Aktion</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id} className="border-t border-gray-100">
<td className="px-4 py-3">
<button type="button" onClick={() => setSelectedUserId(user.id)} className="text-left">
<p className="font-black text-gray-950">{user.name ?? "Unbenannt"}</p>
<p className="text-xs font-semibold text-gray-400">{user.email}</p>
</button>
</td>
<td className="px-4 py-3">
<select
value={user.globalRole === "admin" ? "admin" : "user"}
onChange={(event) => updateRole(user.id, event.target.value as Role)}
className="rounded-full border border-gray-200 px-3 py-2 text-xs font-bold"
>
<option value="user">Benutzer</option>
<option value="admin">Admin</option>
</select>
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-3 py-1 text-xs font-bold ${user.twoFactorEnabled ? "bg-emerald-50 text-emerald-700" : "bg-gray-100 text-gray-500"}`}>
{user.twoFactorEnabled ? "aktiv" : "aus"}
</span>
</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => startRecovery.mutate({ userId: user.id })}
className="rounded-full bg-gray-100 px-3 py-2 text-xs font-black text-gray-700 disabled:opacity-40"
disabled={startRecovery.isPending}
>
2FA zurücksetzen
</button>
</td>
</tr>
))}
{!filteredUsers.length ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-sm font-bold text-gray-400">
Keine Benutzer gefunden.
</td>
</tr>
) : null}
</tbody>
</table>
</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">Neuen Benutzer anlegen</h2>
<div className="mt-4 grid gap-3">
<input value={createForm.name} onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))} placeholder="Name" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={createForm.email} onChange={(event) => setCreateForm((current) => ({ ...current, email: event.target.value }))} placeholder="E-Mail" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input type="password" value={createForm.password} onChange={(event) => setCreateForm((current) => ({ ...current, password: event.target.value }))} placeholder="Initiales Passwort, min. 16 Zeichen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<select value={createForm.globalRole} onChange={(event) => setCreateForm((current) => ({ ...current, globalRole: event.target.value as Role }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
<option value="user">Benutzer</option>
<option value="admin">Admin</option>
</select>
<button type="button" onClick={submitCreate} disabled={createUser.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" />
Benutzer anlegen
</button>
{createUser.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{createUser.error.message}</p> : null}
</div>
</section>
{selectedUser ? (
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-teal-50 text-lg font-black text-[#0F766E]">
{(selectedUser.name ?? selectedUser.email).slice(0, 1).toUpperCase()}
</div>
<div>
<h2 className="font-black text-gray-950">{selectedUser.name ?? selectedUser.email}</h2>
<p className="text-xs font-semibold text-gray-400">{selectedUser.email}</p>
</div>
</div>
<div className="mt-4 grid gap-2 text-sm font-bold text-gray-500">
<p>Erstellt: {new Date(selectedUser.createdAt).toLocaleDateString("de-DE")}</p>
<p>Letzte Aktivität: {selectedUser.lastActivityAt ? new Date(selectedUser.lastActivityAt).toLocaleString("de-DE") : "unbekannt"}</p>
<p className="inline-flex items-center gap-2">
<ShieldCheckIcon className="h-5 w-5 text-[#0F766E]" />
{selectedUser.twoFactorRecoveryRequired ? "2FA-Wiederherstellung offen" : "2FA-Status sauber"}
</p>
</div>
</section>
) : null}
</aside>
</section>
</div>
);
}

View file

@ -0,0 +1,135 @@
"use client";
import { CheckIcon, ExclamationTriangleIcon, ShoppingCartIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { todayKey, weekDates } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
export default function EinkaufslistePage() {
const utils = trpc.useUtils();
const [selectedStoreId, setSelectedStoreId] = useState("");
const [status, setStatus] = useState<string | null>(null);
const days = weekDates(todayKey());
const storesQuery = trpc.health.listStores.useQuery();
const listsQuery = trpc.health.shoppingLists.useQuery({ limit: 5 });
const generateMutation = trpc.health.generateShoppingList.useMutation({
onSuccess: async () => {
await utils.health.shoppingLists.invalidate();
setStatus("Einkaufsliste aus der aktuellen Wochenplanung erstellt.");
},
});
const updateItemMutation = trpc.health.updateShoppingListItem.useMutation({
onSuccess: () => utils.health.shoppingLists.invalidate(),
});
const stores = storesQuery.data ?? [];
const selectedStore = stores.find((store) => store.id === selectedStoreId) ?? stores.find((store) => store.favorite) ?? stores[0];
const latestList = listsQuery.data?.[0];
const items = useMemo(() => latestList?.items ?? [], [latestList]);
const grouped = useMemo(() => {
return items.reduce<Record<string, typeof items>>((groups, item) => {
const key = item.category || "Sonstiges";
groups[key] = [...(groups[key] ?? []), item];
return groups;
}, {});
}, [items]);
function generateList() {
generateMutation.mutate({
startsOn: days[0]!,
endsOn: days[6]!,
storeId: selectedStore?.id ?? null,
name: `Einkauf ${days[0]} bis ${days[6]}`,
});
}
return (
<div className="mx-auto max-w-5xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<ShoppingCartIcon className="h-8 w-8 text-[#0F766E]" />
<div>
<p className="text-sm font-black uppercase text-[#0F766E]">Einkaufen</p>
<h1 className="text-4xl font-black tracking-normal text-gray-950">Einkaufsliste</h1>
</div>
</div>
<p className="mt-3 max-w-2xl text-sm font-semibold text-gray-400">
Persistiert in der Datenbank und direkt aus deiner Essensplanung generiert.
</p>
</header>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<select
value={selectedStoreId || selectedStore?.id || ""}
onChange={(event) => setSelectedStoreId(event.target.value)}
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-black"
>
{stores.map((store) => (
<option key={store.id} value={store.id}>
{store.name}
</option>
))}
</select>
<button type="button" onClick={generateList} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white">
Aus Planung erstellen
</button>
</div>
{latestList ? (
<p className="mt-3 text-sm font-bold text-gray-400">
Aktive Liste: {latestList.name} · {items.length} Artikel
</p>
) : null}
{status ? <p className="mt-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
</section>
{!latestList && !listsQuery.isLoading ? (
<section className="rounded-[2rem] bg-amber-50 p-5 ring-1 ring-amber-100">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-6 w-6 text-amber-700" />
<div>
<h2 className="text-lg font-black text-amber-950">Noch keine Einkaufsliste</h2>
<p className="mt-1 text-sm font-semibold text-amber-800">
Plane zuerst Gerichte oder erstelle direkt eine Liste aus der aktuellen Woche.
</p>
</div>
</div>
</section>
) : null}
<div className="space-y-4">
{Object.entries(grouped).map(([category, list]) => (
<section key={category} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-400">{category}</h2>
<div className="mt-4 divide-y divide-gray-100">
{list.map((item) => (
<div key={item.id} className="grid gap-3 py-4 sm:grid-cols-[56px_1fr_170px] sm:items-center">
<button
type="button"
onClick={() => updateItemMutation.mutate({ id: item.id, checked: !item.checked })}
className={`grid h-12 w-12 place-items-center rounded-2xl ${item.checked ? "bg-green-500 text-white" : "bg-gray-100 text-gray-300"}`}
aria-label="Gekauft umschalten"
title="Gekauft umschalten"
>
<CheckIcon className="h-6 w-6" />
</button>
<div>
<p className={`text-lg font-black ${item.checked ? "text-gray-300 line-through" : "text-gray-950"}`}>{item.name}</p>
<p className="text-sm font-bold text-gray-400">Rezeptmenge: {Math.round(Number(item.amountG))} g</p>
</div>
<input
value={item.purchaseAmount ?? ""}
onChange={(event) => updateItemMutation.mutate({ id: item.id, purchaseAmount: event.target.value })}
placeholder={`${Math.ceil(Number(item.amountG) / 100) * 100} g`}
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm"
/>
</div>
))}
</div>
</section>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,779 @@
"use client";
import {
BeakerIcon,
BellIcon,
ChevronRightIcon,
ClockIcon,
EyeIcon,
EyeSlashIcon,
FireIcon,
MoonIcon,
ScaleIcon,
SunIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { DataPageLayout } from "@/app/_components/data-page-layout";
import { useTheme } from "@/app/_components/theme-provider";
import { authClient } from "@/lib/auth-client";
import { HellthGoals, loadHellthData } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
const PASSWORD_CHARS =
"ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*";
const dietPreferenceOptions = [
{ id: "vegan", label: "Vegan" },
{ id: "vegetarian", label: "Vegetarisch" },
{ id: "meat", label: "Fleisch" },
{ id: "fish", label: "Fisch" },
] as const;
type DietPreference = (typeof dietPreferenceOptions)[number]["id"];
function todayKey() {
return new Date().toISOString().slice(0, 10);
}
function generatePassword(length = 16) {
const bytes = crypto.getRandomValues(new Uint8Array(length));
return Array.from(bytes, (byte) => PASSWORD_CHARS[byte % PASSWORD_CHARS.length]!).join(
"",
);
}
async function toOptimizedAvatarDataUrl(file: File) {
const bitmap = await createImageBitmap(file);
const maxEdge = 320;
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas nicht verfügbar");
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const dataUrl = canvas.toDataURL("image/webp", 0.82);
if (!dataUrl.startsWith("data:image/")) {
throw new Error("Profilbild konnte nicht verarbeitet werden.");
}
if (dataUrl.length > 120_000) {
throw new Error("Profilbild ist nach Komprimierung noch zu groß.");
}
return dataUrl;
}
export default function EinstellungenPage() {
const { isDark, toggleDark } = useTheme();
const utils = trpc.useUtils();
const profileQuery = trpc.users.myProfile.useQuery();
const securityQuery = trpc.security.status.useQuery();
const healthOverviewQuery = trpc.health.overview.useQuery({ date: todayKey() });
const passkeysQuery = authClient.useListPasskeys();
const userKey = profileQuery.data?.id ?? profileQuery.data?.email ?? null;
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [healthGoals, setHealthGoals] = useState<HellthGoals | null>(null);
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [phone, setPhone] = useState("");
const [mobile, setMobile] = useState("");
const [street, setStreet] = useState("");
const [postalCode, setPostalCode] = useState("");
const [city, setCity] = useState("");
const [image, setImage] = useState<string | null>(null);
const [dietPreferences, setDietPreferences] = useState<DietPreference[]>(["vegan", "vegetarian", "meat", "fish"]);
const [mealsPerDay, setMealsPerDay] = useState("3");
const [cooksPerDay, setCooksPerDay] = useState("1");
const [preferredMealPrepDays, setPreferredMealPrepDays] = useState("0");
const [meatFishFrequency, setMeatFishFrequency] = useState<"none" | "once_per_day" | "flexible">("flexible");
const isPlatformAdmin = profileQuery.data?.role === "admin";
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState("");
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [newPasskeyName, setNewPasskeyName] = useState("");
useEffect(() => {
if (profileQuery.data) {
setFirstName(profileQuery.data.firstName ?? profileQuery.data.name ?? "");
setLastName(profileQuery.data.lastName ?? profileQuery.data.name ?? "");
setPhone(profileQuery.data.phone ?? "");
setMobile(profileQuery.data.mobile ?? "");
setStreet(profileQuery.data.street ?? "");
setPostalCode(profileQuery.data.postalCode ?? "");
setCity(profileQuery.data.city ?? "");
setImage(profileQuery.data.image ?? null);
}
}, [profileQuery.data]);
useEffect(() => {
if (!userKey) return;
setHealthGoals(loadHellthData(userKey).goals);
}, [userKey]);
const updateMyProfile = trpc.users.updateMyProfile.useMutation({
onSuccess: async () => {
await Promise.all([utils.users.myProfile.invalidate(), utils.security.status.invalidate()]);
setStatusMessage("Profil gespeichert.");
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error.message);
setStatusMessage(null);
},
});
const changeMyPassword = trpc.users.changeMyPassword.useMutation({
onSuccess: () => {
setCurrentPassword("");
setNewPassword("");
setConfirmNewPassword("");
setStatusMessage("Passwort erfolgreich aktualisiert.");
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error.message);
setStatusMessage(null);
},
});
const updateHealthProfile = trpc.health.upsertProfile.useMutation({
onSuccess: async () => {
await healthOverviewQuery.refetch();
await utils.health.recipeRecommendations.invalidate();
setStatusMessage("Essenspräferenzen gespeichert.");
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error.message);
setStatusMessage(null);
},
});
useEffect(() => {
const profile = healthOverviewQuery.data?.profile;
if (!profile) return;
setDietPreferences((profile.dietPreferences?.length ? profile.dietPreferences : ["vegan", "vegetarian", "meat", "fish"]) as DietPreference[]);
setMealsPerDay(String(profile.mealsPerDay ?? 3));
setCooksPerDay(String(profile.cooksPerDay ?? 1));
setPreferredMealPrepDays(String(profile.preferredMealPrepDays ?? 0));
setMeatFishFrequency((profile.meatFishFrequency as "none" | "once_per_day" | "flexible") ?? "flexible");
}, [healthOverviewQuery.data?.profile]);
function toggleDietPreference(preference: DietPreference) {
setDietPreferences((current) => {
const next = current.includes(preference) ? current.filter((item) => item !== preference) : [...current, preference];
return next.length ? next : current;
});
}
function saveEatingPreferences() {
const profile = healthOverviewQuery.data?.profile;
updateHealthProfile.mutate({
goal: profile?.goal ?? "healthier",
age: profile?.age ?? null,
heightCm: profile?.heightCm == null ? null : Number(profile.heightCm),
currentWeightKg: profile?.currentWeightKg == null ? null : Number(profile.currentWeightKg),
targetWeightKg: profile?.targetWeightKg == null ? null : Number(profile.targetWeightKg),
activityLevel: profile?.activityLevel ?? "moderate",
dietStyle: profile?.dietStyle ?? "flexible",
dietPreferences,
mealsPerDay: Number(mealsPerDay) || 3,
cooksPerDay: Number(cooksPerDay) || 1,
preferredMealPrepDays: Number(preferredMealPrepDays) || 0,
meatFishFrequency,
cookingTime: (profile?.cookingTime as "low" | "medium" | "high" | undefined) ?? "medium",
budgetFocus: profile?.budgetFocus ?? false,
excludedFoods: profile?.excludedFoods ?? null,
biggestChallenge: profile?.biggestChallenge ?? null,
medicalNote: profile?.medicalNote ?? false,
completed: Boolean(profile?.completedAt),
});
}
const isBusy = updateMyProfile.isPending || changeMyPassword.isPending;
const passwordMismatch =
confirmNewPassword.length > 0 && newPassword !== confirmNewPassword;
const canSaveProfile = useMemo(
() =>
!!firstName.trim() &&
firstName.trim().length >= 2 &&
(firstName.trim() !== (profileQuery.data?.firstName ?? profileQuery.data?.name ?? "") ||
(lastName.trim() || null) !== (profileQuery.data?.lastName ?? null) ||
(phone.trim() || null) !== (profileQuery.data?.phone ?? null) ||
(mobile.trim() || null) !== (profileQuery.data?.mobile ?? null) ||
(street.trim() || null) !== (profileQuery.data?.street ?? null) ||
(postalCode.trim() || null) !== (profileQuery.data?.postalCode ?? null) ||
(city.trim() || null) !== (profileQuery.data?.city ?? null) ||
(image ?? null) !== (profileQuery.data?.image ?? null)),
[
firstName,
lastName,
phone,
mobile,
street,
postalCode,
city,
image,
profileQuery.data?.firstName,
profileQuery.data?.name,
profileQuery.data?.lastName,
profileQuery.data?.phone,
profileQuery.data?.mobile,
profileQuery.data?.street,
profileQuery.data?.postalCode,
profileQuery.data?.city,
profileQuery.data?.image,
],
);
async function handleAddPasskey() {
setErrorMessage(null);
setStatusMessage(null);
const result = await authClient.passkey.addPasskey({
name: newPasskeyName.trim() || undefined,
});
if (result.error) {
setErrorMessage(result.error.message ?? "Passkey konnte nicht hinzugefügt werden.");
return;
}
setStatusMessage("Passkey erfolgreich hinzugefügt.");
setNewPasskeyName("");
await passkeysQuery.refetch();
}
async function handleImageUpload(file: File) {
if (!file.type.startsWith("image/")) {
setErrorMessage("Bitte eine Bilddatei auswählen.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setErrorMessage("Profilbild ist zu groß. Maximal 5 MB.");
return;
}
try {
const dataUrl = await toOptimizedAvatarDataUrl(file);
setImage(dataUrl);
setErrorMessage(null);
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "Profilbild konnte nicht verarbeitet werden.",
);
}
}
return (
<DataPageLayout title="Einstellungen" description="Persönliches Profil und Sicherheit">
<div className="mb-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-400">Ziele</h2>
<div className="mt-5 divide-y divide-gray-100">
{[
{ icon: FireIcon, label: "Kalorien", value: `${healthGoals?.calories ?? 2000} kcal`, color: "text-orange-500" },
{ icon: ScaleIcon, label: "Makros", value: `${healthGoals?.carbs ?? 200}/${healthGoals?.fat ?? 67}/${healthGoals?.protein ?? 150} g`, color: "text-blue-500" },
{ icon: ClockIcon, label: "Fasten", value: `${healthGoals?.fastingHours ?? 12} h`, color: "text-rose-500" },
{ icon: BeakerIcon, label: "Wasser", value: `${healthGoals?.drinkMl ?? 2500} ml`, color: "text-blue-500" },
{ icon: ScaleIcon, label: "Gewicht", value: `${healthGoals?.weightKg ?? 120} kg`, color: "text-orange-500" },
].map((item) => (
<Link key={item.label} href="/ziele" className="flex w-full items-center gap-4 py-4 text-left">
<item.icon className={`h-7 w-7 ${item.color}`} />
<span className="flex-1 text-lg font-black text-gray-950">{item.label}</span>
<span className="text-lg font-black text-gray-400">{item.value}</span>
<ChevronRightIcon className="h-5 w-5 text-gray-300" />
</Link>
))}
</div>
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-400">Rezept-Präferenzen</h2>
<div className="mt-5 space-y-4">
<div>
<p className="text-sm font-black uppercase text-gray-400">Ernährung</p>
<div className="mt-2 grid grid-cols-2 gap-2">
{dietPreferenceOptions.map((option) => (
<button
key={option.id}
type="button"
onClick={() => toggleDietPreference(option.id)}
className={`rounded-full px-3 py-2 text-sm font-black ${
dietPreferences.includes(option.id) ? "bg-[#0F766E] text-white" : "bg-gray-100 text-gray-500"
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<label className="space-y-1">
<span className="text-xs font-black uppercase text-gray-400">Mahlzeiten</span>
<input value={mealsPerDay} onChange={(event) => setMealsPerDay(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold" />
</label>
<label className="space-y-1">
<span className="text-xs font-black uppercase text-gray-400">Kochen/Tag</span>
<input value={cooksPerDay} onChange={(event) => setCooksPerDay(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold" />
</label>
<label className="space-y-1">
<span className="text-xs font-black uppercase text-gray-400">Mealprep</span>
<input value={preferredMealPrepDays} onChange={(event) => setPreferredMealPrepDays(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold" />
</label>
</div>
<label className="block space-y-1">
<span className="text-xs font-black uppercase text-gray-400">Fleisch/Fisch</span>
<select
value={meatFishFrequency}
onChange={(event) => setMeatFishFrequency(event.target.value as "none" | "once_per_day" | "flexible")}
className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold"
>
<option value="none">nie</option>
<option value="once_per_day">maximal 1x am Tag</option>
<option value="flexible">flexibel</option>
</select>
</label>
<button
type="button"
onClick={saveEatingPreferences}
disabled={updateHealthProfile.isPending}
className="w-full rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-50"
>
Präferenzen speichern
</button>
</div>
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-400">Anwendung</h2>
<div className="mt-5 space-y-4">
<div className="flex items-center gap-4">
<BellIcon className="h-7 w-7 text-green-500" />
<div className="flex-1">
<p className="text-lg font-black text-gray-950">Tägliche Erinnerung</p>
<p className="text-sm font-bold text-gray-400">Tracking und Planung</p>
</div>
<span className="relative inline-flex h-8 w-14 rounded-full bg-green-500">
<span className="absolute right-1 top-1 h-6 w-6 rounded-full bg-white" />
</span>
</div>
<div className="flex items-center gap-4">
{isDark ? <MoonIcon className="h-7 w-7 text-indigo-400" /> : <SunIcon className="h-7 w-7 text-amber-500" />}
<div className="flex-1">
<p className="text-lg font-black text-gray-950">Dunkles Design</p>
<p className="text-sm font-bold text-gray-400">{isDark ? "aktiv" : "inaktiv"}</p>
</div>
<button
type="button"
onClick={toggleDark}
className={`relative inline-flex h-8 w-14 rounded-full ${isDark ? "bg-[#0F766E]" : "bg-gray-200"}`}
aria-label="Dunkles Design umschalten"
>
<span className={`absolute top-1 h-6 w-6 rounded-full bg-white transition ${isDark ? "right-1" : "left-1"}`} />
</button>
</div>
</div>
</section>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<section className="lg:col-span-2 bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div>
<h2 className="text-lg font-semibold text-[#333333]">Mein Profil</h2>
<p className="text-sm text-gray-500 mt-1">
Du kannst dein eigenes Profil bearbeiten. Höhere Admins können dich zusätzlich in
der Benutzerverwaltung bearbeiten.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Name</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
placeholder="Vorname"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Nachname</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
value={lastName}
onChange={(event) => setLastName(event.target.value)}
placeholder="Nachname"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Profilbild</label>
<div className="flex items-center gap-3">
<div className="w-16 h-16 rounded-full bg-gray-100 border border-gray-200 overflow-hidden shrink-0">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="Profilbild" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-sm text-gray-400">
-
</div>
)}
</div>
<div className="space-y-2">
<label className="inline-flex px-3 py-2 rounded-lg bg-gray-100 text-gray-700 text-sm cursor-pointer">
Bild hochladen
<input
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void handleImageUpload(file);
}
event.currentTarget.value = "";
}}
/>
</label>
<p className="text-xs text-gray-500">Bild wird automatisch skaliert und komprimiert.</p>
<button
type="button"
onClick={() => setImage(null)}
className="block text-xs text-red-600 hover:text-red-700"
>
Bild entfernen
</button>
</div>
</div>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1.5">E-Mail</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm bg-gray-50 text-gray-500"
value={profileQuery.data?.email ?? ""}
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Telefon</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="Telefon"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Mobil</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
value={mobile}
onChange={(event) => setMobile(event.target.value)}
placeholder="Mobil"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1.5">Straße</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
value={street}
onChange={(event) => setStreet(event.target.value)}
placeholder="Straße und Hausnummer"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">PLZ</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
value={postalCode}
onChange={(event) => setPostalCode(event.target.value)}
placeholder="PLZ"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Ort</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
value={city}
onChange={(event) => setCity(event.target.value)}
placeholder="Ort"
/>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => {
setStatusMessage(null);
setErrorMessage(null);
updateMyProfile.mutate({
firstName: firstName.trim(),
lastName: lastName.trim() || null,
phone: phone.trim() || null,
mobile: mobile.trim() || null,
street: street.trim() || null,
postalCode: postalCode.trim() || null,
city: city.trim() || null,
image,
});
}}
disabled={isBusy || !canSaveProfile}
className="px-4 py-2 rounded-lg bg-[#0F766E] text-white text-sm font-medium disabled:opacity-50"
>
Profil speichern
</button>
</div>
</section>
<section className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold text-[#333333]">Sicherheit</h2>
<p className="text-sm text-gray-600">
2FA Status:{" "}
<strong>{securityQuery.data?.twoFactorEnabled ? "aktiv" : "nicht aktiv"}</strong>
</p>
{securityQuery.data?.twoFactorRecoveryRequired && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
2FA Recovery aktiv: Bitte OTP neu einrichten.
</p>
)}
<div className="flex flex-wrap gap-2">
<Link
href="/sicherheit/2fa-setup"
className="inline-flex px-4 py-2 rounded-lg bg-gray-900 text-white text-sm"
>
2FA verwalten
</Link>
</div>
<div className="border-t border-gray-100 pt-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-800">Passkeys</h3>
<p className="text-sm text-gray-600">
Passkeys erlauben die Anmeldung per Gerät (z. B. Windows Hello, Touch ID oder
Passwortmanager).
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newPasskeyName}
onChange={(event) => setNewPasskeyName(event.target.value)}
placeholder="Name (optional), z. B. Laptop"
className="flex-1 border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
/>
<button
type="button"
onClick={() => void handleAddPasskey()}
disabled={isBusy}
className="px-4 py-2 rounded-lg bg-gray-900 text-white text-sm disabled:opacity-50"
>
Passkey hinzufügen
</button>
</div>
<div className="space-y-2">
{passkeysQuery.isPending && (
<p className="text-xs text-gray-500">Passkeys werden geladen...</p>
)}
{passkeysQuery.error && (
<p className="text-xs text-red-600">
{passkeysQuery.error.message ?? "Passkeys konnten nicht geladen werden."}
</p>
)}
{!passkeysQuery.isPending &&
!passkeysQuery.error &&
(passkeysQuery.data?.length ?? 0) === 0 && (
<p className="text-xs text-gray-500">Noch keine Passkeys hinterlegt.</p>
)}
{(passkeysQuery.data ?? []).map((item) => (
<div
key={item.id}
className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-700"
>
<p className="font-medium text-gray-800">{item.name || "Unbenannter Passkey"}</p>
<p className="text-gray-500">Erstellt: {new Date(item.createdAt).toLocaleString("de-DE")}</p>
</div>
))}
</div>
</div>
<div className="border-t border-gray-100 pt-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-800">Passwort zurücksetzen</h3>
<div className="relative">
<input
type={showCurrentPassword ? "text" : "password"}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 pr-11 text-sm"
placeholder="Aktuelles Passwort"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
<button
type="button"
onClick={() => setShowCurrentPassword((value) => !value)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label={showCurrentPassword ? "Passwort verbergen" : "Passwort anzeigen"}
>
{showCurrentPassword ? (
<EyeSlashIcon className="w-4 h-4" />
) : (
<EyeIcon className="w-4 h-4" />
)}
</button>
</div>
<div className="relative">
<input
type={showNewPassword ? "text" : "password"}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 pr-11 text-sm"
placeholder="Neues Passwort (mind. 16 Zeichen)"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
<button
type="button"
onClick={() => setShowNewPassword((value) => !value)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label={showNewPassword ? "Passwort verbergen" : "Passwort anzeigen"}
>
{showNewPassword ? (
<EyeSlashIcon className="w-4 h-4" />
) : (
<EyeIcon className="w-4 h-4" />
)}
</button>
</div>
<div className="relative">
<input
type={showConfirmPassword ? "text" : "password"}
className={`w-full border rounded-lg px-3 py-2.5 pr-11 text-sm ${
passwordMismatch ? "border-red-300" : "border-gray-200"
}`}
placeholder="Neues Passwort bestätigen"
value={confirmNewPassword}
onChange={(event) => setConfirmNewPassword(event.target.value)}
/>
<button
type="button"
onClick={() => setShowConfirmPassword((value) => !value)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label={showConfirmPassword ? "Passwort verbergen" : "Passwort anzeigen"}
>
{showConfirmPassword ? (
<EyeSlashIcon className="w-4 h-4" />
) : (
<EyeIcon className="w-4 h-4" />
)}
</button>
</div>
{passwordMismatch && (
<p className="text-xs text-red-600">Die neuen Passwörter stimmen nicht überein.</p>
)}
<button
type="button"
onClick={() => {
const generated = generatePassword(16);
setNewPassword(generated);
setConfirmNewPassword(generated);
setShowNewPassword(true);
setShowConfirmPassword(true);
}}
className="px-3 py-2 rounded-lg bg-gray-100 text-gray-700 text-sm w-fit"
>
Passwort generieren (16)
</button>
<button
onClick={() => {
setStatusMessage(null);
setErrorMessage(null);
changeMyPassword.mutate({
currentPassword,
newPassword,
});
}}
disabled={
isBusy ||
!currentPassword ||
newPassword.length < 16 ||
confirmNewPassword.length < 16 ||
passwordMismatch
}
className="w-full px-4 py-2 rounded-lg bg-[#0F766E] text-white text-sm font-medium disabled:opacity-50"
>
Passwort aktualisieren
</button>
</div>
</section>
<section className="lg:col-span-3 bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<h2 className="text-lg font-semibold text-[#333333]">Darstellung</h2>
<p className="text-sm text-gray-500 mt-1">Erscheinungsbild der Anwendung anpassen.</p>
</div>
<div className="flex items-center justify-between rounded-lg border border-gray-100 bg-gray-50 px-4 py-3">
<div className="flex items-center gap-3">
{isDark ? (
<MoonIcon className="h-5 w-5 text-indigo-400" />
) : (
<SunIcon className="h-5 w-5 text-amber-500" />
)}
<div>
<p className="text-sm font-medium text-gray-900">Dunkles Design</p>
<p className="text-xs text-gray-500">
{isDark ? "Dunkles Design ist aktiv" : "Helles Design ist aktiv"}
</p>
</div>
</div>
<button
type="button"
onClick={toggleDark}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none ${
isDark ? "bg-[#0F766E]" : "bg-gray-200"
}`}
role="switch"
aria-checked={isDark}
aria-label="Dunkles Design umschalten"
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ${
isDark ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
</section>
</div>
{profileQuery.isLoading && (
<div className="mt-6 text-sm rounded-lg border border-gray-200 bg-white px-4 py-3 text-gray-500">
Profil wird geladen...
</div>
)}
{(profileQuery.error || securityQuery.error) && (
<div className="mt-6 text-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{profileQuery.error?.message ?? securityQuery.error?.message}
</div>
)}
{statusMessage && (
<div className="mt-6 text-sm rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-green-700">
{statusMessage}
</div>
)}
{errorMessage && (
<div className="mt-6 text-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{errorMessage}
</div>
)}
</DataPageLayout>
);
}

View file

@ -0,0 +1,169 @@
"use client";
import { CheckIcon } from "@heroicons/react/24/outline";
import { useEffect, useMemo, useState } from "react";
import { trpc } from "@/lib/trpc";
type TemplateDraft = {
id: string;
name: string;
category: string;
defaultMinutes: number;
defaultCalories: number;
defaultPoints: string;
};
function numberInput(value: unknown) {
return String(Number(value) || 0);
}
export default function PunkteEinstellungenPage() {
const utils = trpc.useUtils();
const settingsQuery = trpc.health.pointSettings.useQuery();
const [recipeSubmissionPoints, setRecipeSubmissionPoints] = useState("25");
const [clubActivityDefaultPoints, setClubActivityDefaultPoints] = useState("100");
const [templates, setTemplates] = useState<TemplateDraft[]>([]);
const [saved, setSaved] = useState(false);
const updateMutation = trpc.health.updatePointSettings.useMutation({
onSuccess: async () => {
await Promise.all([
utils.health.pointSettings.invalidate(),
utils.health.pointDefaults.invalidate(),
utils.health.listActivityTemplates.invalidate(),
]);
setSaved(true);
window.setTimeout(() => setSaved(false), 1600);
},
});
useEffect(() => {
if (!settingsQuery.data) return;
setRecipeSubmissionPoints(numberInput(settingsQuery.data.recipeSubmissionPoints));
setClubActivityDefaultPoints(numberInput(settingsQuery.data.clubActivityDefaultPoints));
setTemplates(settingsQuery.data.activityTemplates.map((template) => ({
id: template.id,
name: template.name,
category: template.category,
defaultMinutes: template.defaultMinutes,
defaultCalories: template.defaultCalories,
defaultPoints: numberInput(template.defaultPoints),
})));
}, [settingsQuery.data]);
const groupedTemplates = useMemo(() => {
const groups = new Map<string, TemplateDraft[]>();
for (const template of templates) {
const current = groups.get(template.category) ?? [];
current.push(template);
groups.set(template.category, current);
}
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b, "de-DE"));
}, [templates]);
function updateTemplate(id: string, value: string) {
setTemplates((current) => current.map((template) => (
template.id === id ? { ...template, defaultPoints: value } : template
)));
}
function save() {
updateMutation.mutate({
recipeSubmissionPoints: Number(recipeSubmissionPoints) || 0,
clubActivityDefaultPoints: Number(clubActivityDefaultPoints) || 0,
activityTemplates: templates.map((template) => ({
id: template.id,
defaultPoints: Number(template.defaultPoints) || 0,
})),
});
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Admin</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Punktevergabe</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Globale Punktwerte für Hellfireclub, Rezeptvorschläge und Aktivitätskatalog.
</p>
</header>
<section className="grid gap-4 lg:grid-cols-2">
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Systempunkte</h2>
<div className="mt-4 grid gap-3">
<label className="grid gap-2">
<span className="text-sm font-black text-gray-500">Rezeptvorschlag einreichen</span>
<input
type="number"
value={recipeSubmissionPoints}
onChange={(event) => setRecipeSubmissionPoints(event.target.value)}
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
/>
</label>
<label className="grid gap-2">
<span className="text-sm font-black text-gray-500">Hellfireclub-Aktivität erstellen, Default</span>
<input
type="number"
value={clubActivityDefaultPoints}
onChange={(event) => setClubActivityDefaultPoints(event.target.value)}
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
/>
</label>
</div>
</div>
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Hinweis</h2>
<p className="mt-4 text-sm font-semibold text-gray-500">
Bestehende Punkte-Logs bleiben unverändert. Änderungen gelten für neue Aktivitäten,
neue Rezeptvorschläge und neue Einträge aus dem Aktivitätskatalog.
</p>
<button
type="button"
onClick={save}
disabled={updateMutation.isPending}
className="mt-5 inline-flex w-full items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
>
<CheckIcon className="h-5 w-5" />
Punkte speichern
</button>
{saved ? <p className="mt-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">Punkte gespeichert.</p> : null}
{updateMutation.error ? <p className="mt-3 rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{updateMutation.error.message}</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ätskatalog</h2>
<div className="mt-5 grid gap-5">
{groupedTemplates.map(([category, entries]) => (
<div key={category}>
<h3 className="text-sm font-black uppercase text-gray-400">{category}</h3>
<div className="mt-2 grid gap-2">
{entries.map((template) => (
<div key={template.id} className="grid gap-3 rounded-3xl bg-gray-50 p-4 md:grid-cols-[1fr_120px_120px_130px] md:items-center">
<div>
<p className="text-sm font-black text-gray-950">{template.name}</p>
<p className="text-xs font-bold text-gray-400">{template.defaultMinutes} min · {template.defaultCalories} kcal</p>
</div>
<span className="text-xs font-bold text-gray-400">{template.id}</span>
<span className="text-xs font-bold text-gray-400">{template.category}</span>
<input
type="number"
value={template.defaultPoints}
onChange={(event) => updateTemplate(template.id, event.target.value)}
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-semibold"
/>
</div>
))}
</div>
</div>
))}
{!templates.length && !settingsQuery.isLoading ? (
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Keine globalen Aktivitätsvorlagen gefunden.</p>
) : null}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,245 @@
"use client";
import {
BeakerIcon,
BellAlertIcon,
CheckCircleIcon,
ClockIcon,
FireIcon,
} from "@heroicons/react/24/outline";
import { useEffect, useState } from "react";
import { useSession } from "@/lib/auth-client";
import {
ReminderKind,
ReminderRule,
ReminderSettings,
defaultReminderSettings,
loadHellthData,
saveHellthData,
} from "@/lib/hellth-data";
const reminderMeta: Record<
ReminderKind,
{ title: string; description: string; icon: typeof BeakerIcon; color: string }
> = {
water: {
title: "Wasser trinken",
description: "Regelmäßig kleine Trinkimpulse statt abends alles nachholen.",
icon: BeakerIcon,
color: "text-blue-500",
},
snack: {
title: "Snack prüfen",
description: "Hilft, geplante Snacks bewusst zu essen und ungeplantes Snacken zu vermeiden.",
icon: FireIcon,
color: "text-orange-500",
},
movement: {
title: "Kurz bewegen",
description: "Steh vom Schreibtisch auf, streck dich oder geh ein paar Schritte.",
icon: ClockIcon,
color: "text-emerald-500",
},
};
function permissionLabel(permission: NotificationPermission | "unsupported") {
if (permission === "granted") return "Benachrichtigungen sind erlaubt.";
if (permission === "denied") return "Benachrichtigungen sind im Browser blockiert.";
if (permission === "unsupported") return "Dieser Browser unterstützt keine Web-Benachrichtigungen.";
return "Benachrichtigungen sind noch nicht aktiviert.";
}
export default function ErinnerungenPage() {
const { data: session } = useSession();
const userKey = session?.user?.id ?? session?.user?.email ?? null;
const [settings, setSettings] = useState<ReminderSettings>(defaultReminderSettings);
const [permission, setPermission] = useState<NotificationPermission | "unsupported">("default");
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!userKey) return;
setSettings(loadHellthData(userKey).reminders);
setPermission(typeof Notification === "undefined" ? "unsupported" : Notification.permission);
}, [userKey]);
function persist(nextSettings: ReminderSettings) {
if (!userKey) return;
setSettings(nextSettings);
const data = loadHellthData(userKey);
saveHellthData({ ...data, reminders: nextSettings }, userKey);
setSaved(true);
window.setTimeout(() => setSaved(false), 1500);
}
function updateRule(kind: ReminderKind, patch: Partial<ReminderRule>) {
persist({
...settings,
rules: {
...settings.rules,
[kind]: { ...settings.rules[kind], ...patch },
},
});
}
async function requestPermission() {
if (typeof Notification === "undefined") {
setPermission("unsupported");
return;
}
const result = await Notification.requestPermission();
setPermission(result);
persist({
...settings,
enabled: result === "granted" ? settings.enabled : false,
permissionRequestedAt: new Date().toISOString(),
});
}
async function sendTest(kind: ReminderKind) {
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
const rule = settings.rules[kind];
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(`Hellth Hub: ${reminderMeta[kind].title}`, {
body: rule.message,
tag: `hellth-test-${kind}`,
});
return;
} catch {
// Fall through to regular notification.
}
}
new Notification(`Hellth Hub: ${reminderMeta[kind].title}`, { body: rule.message });
}
return (
<div className="mx-auto max-w-5xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-black uppercase text-[#0F766E]">Coach Impulse</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Erinnerungen</h1>
<p className="mt-3 max-w-2xl text-sm font-semibold text-gray-400">
Kleine Push-Impulse für Wasser, geplante Snacks und Bewegung im Arbeitsalltag.
</p>
</div>
<BellAlertIcon className="h-10 w-10 text-[#0F766E]" />
</div>
</header>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-black text-gray-950">Benachrichtigungen</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">{permissionLabel(permission)}</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => void requestPermission()}
className="rounded-full bg-gray-950 px-5 py-3 text-sm font-black text-white"
>
Berechtigung anfragen
</button>
<button
type="button"
disabled={permission !== "granted"}
onClick={() => persist({ ...settings, enabled: !settings.enabled })}
className={`rounded-full px-5 py-3 text-sm font-black disabled:opacity-40 ${
settings.enabled ? "bg-emerald-600 text-white" : "bg-gray-100 text-gray-950"
}`}
>
{settings.enabled ? "Aktiv" : "Pausiert"}
</button>
</div>
</div>
</section>
<div className="grid gap-4 lg:grid-cols-3">
{(Object.keys(settings.rules) as ReminderKind[]).map((kind) => {
const meta = reminderMeta[kind];
const rule = settings.rules[kind];
return (
<section key={kind} className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start gap-3">
<meta.icon className={`h-8 w-8 ${meta.color}`} />
<div>
<h2 className="text-xl font-black text-gray-950">{meta.title}</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">{meta.description}</p>
</div>
</div>
<label className="mt-5 flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm font-black text-gray-950">
Aktivieren
<input
type="checkbox"
checked={rule.enabled}
onChange={(event) => updateRule(kind, { enabled: event.target.checked })}
/>
</label>
<div className="mt-4 grid grid-cols-2 gap-2">
<label className="text-xs font-black uppercase text-gray-400">
Von
<input
type="time"
value={rule.startTime}
onChange={(event) => updateRule(kind, { startTime: event.target.value })}
className="mt-1 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm text-gray-950"
/>
</label>
<label className="text-xs font-black uppercase text-gray-400">
Bis
<input
type="time"
value={rule.endTime}
onChange={(event) => updateRule(kind, { endTime: event.target.value })}
className="mt-1 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm text-gray-950"
/>
</label>
</div>
<label className="mt-4 block text-xs font-black uppercase text-gray-400">
Intervall Minuten
<input
type="number"
min={15}
step={15}
value={rule.intervalMinutes}
onChange={(event) => updateRule(kind, { intervalMinutes: Number(event.target.value) || 60 })}
className="mt-1 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm text-gray-950"
/>
</label>
<label className="mt-4 block text-xs font-black uppercase text-gray-400">
Nachricht
<textarea
value={rule.message}
onChange={(event) => updateRule(kind, { message: event.target.value })}
className="mt-1 min-h-24 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-semibold normal-case text-gray-950"
/>
</label>
<button
type="button"
disabled={permission !== "granted"}
onClick={() => void sendTest(kind)}
className="mt-4 w-full rounded-full bg-gray-100 px-4 py-3 text-sm font-black text-gray-950 disabled:opacity-40"
>
Test senden
</button>
</section>
);
})}
</div>
{saved ? (
<div className="fixed bottom-24 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-full bg-emerald-600 px-5 py-3 text-sm font-black text-white shadow-lg">
<CheckCircleIcon className="h-5 w-5" />
Gespeichert
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,201 @@
"use client";
import {
ArrowTrendingDownIcon,
CalendarDaysIcon,
PlusIcon,
ScaleIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { 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",
year: "numeric",
});
}
function WeightChart({ entries }: { entries: Array<{ id?: string; date: string; weightKg: number }> }) {
if (entries.length < 2) {
return (
<div className="grid min-h-56 place-items-center rounded-[2rem] bg-gray-50 p-6 text-center text-sm font-bold text-gray-400">
Noch zu wenig Messpunkte für einen Verlauf.
</div>
);
}
const min = Math.min(...entries.map((entry) => entry.weightKg));
const max = Math.max(...entries.map((entry) => entry.weightKg));
const range = Math.max(0.5, max - min);
return (
<div className="mt-5 flex min-h-64 items-end gap-2 rounded-[2rem] bg-gray-50 p-4">
{entries.map((entry) => {
const height = 18 + ((entry.weightKg - min) / range) * 76;
return (
<div key={entry.id ?? entry.date} className="flex min-w-8 flex-1 flex-col items-center justify-end gap-2">
<div className="w-full rounded-full bg-[#0F766E]" style={{ height: `${height}%` }} title={`${entry.weightKg} kg`} />
<p className="text-[10px] font-bold text-gray-400">{entry.date.slice(5)}</p>
</div>
);
})}
</div>
);
}
export default function GewichtPage() {
const utils = trpc.useUtils();
const [date, setDate] = useState(todayKey());
const [weightKg, setWeightKg] = useState("");
const [note, setNote] = useState("");
const [status, setStatus] = useState<string | null>(null);
const historyQuery = trpc.health.weightHistory.useQuery({ limit: 120 });
const saveMutation = trpc.health.upsertWeightLog.useMutation({
onSuccess: async () => {
await Promise.all([
utils.health.weightHistory.invalidate(),
utils.health.statsSummary.invalidate(),
utils.health.overview.invalidate({ date }),
]);
setWeightKg("");
setNote("");
setStatus("Gewicht gespeichert.");
},
});
const deleteMutation = trpc.health.deleteWeightLog.useMutation({
onSuccess: async () => {
await Promise.all([
utils.health.weightHistory.invalidate(),
utils.health.statsSummary.invalidate(),
]);
setStatus("Eintrag entfernt.");
},
});
const history = historyQuery.data;
const entries = history?.entries ?? [];
const latest = history?.latest;
const sortedRecent = useMemo(() => [...entries].reverse(), [entries]);
function saveWeight() {
const value = Number(weightKg.replace(",", "."));
if (!value || value <= 0) return;
saveMutation.mutate({
date,
weightKg: value,
note: 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-emerald-300">Gewicht</p>
<h1 className="mt-2 text-4xl font-black tracking-normal">Verlauf statt Tageslaune</h1>
<p className="mt-3 max-w-3xl text-sm font-semibold text-white/60">
Einzelne Werte schwanken. Hellth Hub schaut auf den Trend und macht Prognosen erst, wenn genug echte Daten da 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">
<ScaleIcon className="h-14 w-14" />
</div>
</div>
</header>
<section className="grid gap-4 lg:grid-cols-[1fr_0.75fr]">
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-xl font-black text-gray-950">Gewichtshistorie</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">{entries.length} Messpunkt(e) in der Datenbank</p>
</div>
<div className="rounded-full bg-gray-100 px-4 py-2 text-sm font-black text-gray-600">
Ziel: {history?.targetWeightKg ? `${history.targetWeightKg} kg` : "nicht gesetzt"}
</div>
</div>
<WeightChart entries={entries} />
</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">
<PlusIcon className="h-7 w-7 text-[#0F766E]" />
<h2 className="text-xl font-black text-gray-950">Messwert eintragen</h2>
</div>
<div className="mt-4 grid gap-3">
<input type="date" value={date} onChange={(event) => setDate(event.target.value)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<input value={weightKg} onChange={(event) => setWeightKg(event.target.value)} placeholder={latest ? `${latest.weightKg} kg` : "Gewicht kg"} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<textarea value={note} onChange={(event) => setNote(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={saveWeight} className="rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white">
Speichern
</button>
{status ? <p className="rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
</div>
</section>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<ArrowTrendingDownIcon className="h-7 w-7 text-emerald-600" />
<h2 className="text-xl font-black text-gray-950">Prognose</h2>
</div>
<p className="mt-3 text-sm font-semibold text-gray-500">{history?.projectionMessage ?? "Noch keine Prognose verfügbar."}</p>
{history?.targetDate ? (
<p className="mt-4 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-black text-emerald-800">
Hochrechnung Zieltermin: {formatDate(history.targetDate)}
</p>
) : null}
<p className="mt-3 text-xs font-bold text-gray-400">
Vertrauen: {history?.confidence === "medium" ? "mittel" : history?.confidence === "low" ? "niedrig" : "noch nicht genug Daten"}
</p>
</section>
</aside>
</section>
<section className="grid gap-4 md:grid-cols-3">
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-gray-400">Aktuell</p>
<p className="mt-4 text-4xl font-black text-gray-950">{latest ? `${latest.weightKg} kg` : "-"}</p>
</div>
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-gray-400">Seit Start</p>
<p className="mt-4 text-4xl font-black text-gray-950">{history ? `${history.deltaFromFirst} kg` : "-"}</p>
</div>
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-gray-400">Letzte Änderung</p>
<p className="mt-4 text-4xl font-black text-gray-950">{history ? `${history.deltaFromPrevious} kg` : "-"}</p>
</div>
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<CalendarDaysIcon className="h-7 w-7 text-gray-400" />
<h2 className="text-xl font-black text-gray-950">Messwerte</h2>
</div>
<div className="mt-4 divide-y divide-gray-100">
{sortedRecent.map((entry) => (
<div key={entry.id} className="flex items-center gap-4 py-4">
<div className="min-w-0 flex-1">
<p className="text-lg font-black text-gray-950">{entry.weightKg} kg</p>
<p className="text-sm font-bold text-gray-400">
{formatDate(entry.date)}
{entry.note ? ` · ${entry.note}` : ""}
</p>
</div>
<button type="button" onClick={() => deleteMutation.mutate({ id: entry.id })} className="grid h-10 w-10 place-items-center rounded-2xl bg-gray-100 text-gray-400" aria-label="Löschen" title="Löschen">
<TrashIcon className="h-5 w-5" />
</button>
</div>
))}
{!entries.length ? <p className="py-5 text-sm font-bold text-gray-400">Noch keine Messwerte gespeichert.</p> : null}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,477 @@
"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>
);
}

View file

@ -0,0 +1,23 @@
import { AuthGuard } from "@/app/_components/auth-guard";
import { MobileNav } from "@/app/_components/mobile-nav";
import { ReminderRunner } from "@/app/_components/reminder-runner";
import { Sidebar } from "@/app/_components/sidebar";
export default function AuthedLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthGuard>
<ReminderRunner />
<div className="flex min-h-screen bg-[#F9F9F9] dark:bg-neutral-950">
<Sidebar />
<div className="min-w-0 flex-1">
<MobileNav />
<main className="p-4 pb-24 sm:p-6 lg:p-8">{children}</main>
</div>
</div>
</AuthGuard>
);
}

View file

@ -0,0 +1,451 @@
"use client";
import {
ArrowPathIcon,
CameraIcon,
CheckCircleIcon,
HandThumbDownIcon,
HandThumbUpIcon,
MagnifyingGlassIcon,
PlusIcon,
ScaleIcon,
} from "@heroicons/react/24/outline";
import { useState } from "react";
import { parseNutritionLabel, PortionUnit, unitLabel } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
const portionUnits: PortionUnit[] = ["g", "ml", "tsp", "tbsp", "piece", "slice", "cup", "pack", "bottle"];
type FoodForm = {
name: string;
brand: string;
category: string;
kcalPer100g: string;
proteinPer100g: string;
carbsPer100g: string;
fatPer100g: string;
servingG: string;
preferredUnit: PortionUnit;
densityGPerMl: string;
portionLabel: string;
portionUnit: PortionUnit;
portionAmount: string;
portionGramsEquivalent: string;
barcode: string;
availableEverywhere: boolean;
};
const emptyForm: FoodForm = {
name: "",
brand: "",
category: "Sonstiges",
kcalPer100g: "",
proteinPer100g: "",
carbsPer100g: "",
fatPer100g: "",
servingG: "100",
preferredUnit: "g",
densityGPerMl: "",
portionLabel: "",
portionUnit: "piece",
portionAmount: "1",
portionGramsEquivalent: "",
barcode: "",
availableEverywhere: true,
};
function toNumber(value: string) {
return Number(value.replace(",", ".")) || 0;
}
function formFromFood(food: Record<string, unknown>): FoodForm {
return {
...emptyForm,
name: String(food.name ?? ""),
brand: String(food.brand ?? ""),
category: String(food.category ?? "Sonstiges"),
kcalPer100g: food.kcalPer100g != null ? String(food.kcalPer100g) : "",
proteinPer100g: food.proteinPer100g != null ? String(food.proteinPer100g) : "",
carbsPer100g: food.carbsPer100g != null ? String(food.carbsPer100g) : "",
fatPer100g: food.fatPer100g != null ? String(food.fatPer100g) : "",
servingG: food.servingG != null ? String(food.servingG) : "100",
barcode: String(food.barcode ?? ""),
};
}
export default function LebensmittelPage() {
const utils = trpc.useUtils();
const [query, setQuery] = useState("");
const [form, setForm] = useState<FoodForm>(emptyForm);
const [barcode, setBarcode] = useState("");
const [ocrText, setOcrText] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [ocrBusy, setOcrBusy] = useState(false);
const [lookupBusy, setLookupBusy] = useState(false);
const [sourceFoodId, setSourceFoodId] = useState<string | null>(null);
const [sourceQuery, setSourceQuery] = useState("");
const foodsQuery = trpc.health.listFoods.useQuery({ query });
const sourceSearchQuery = trpc.health.searchOpenFoodFacts.useQuery(
{ query: sourceQuery, pageSize: 8 },
{ enabled: sourceQuery.trim().length >= 2 },
);
const saveFoodMutation = trpc.health.saveFood.useMutation({
onSuccess: async (result) => {
await utils.health.listFoods.invalidate();
setForm(emptyForm);
setStatus(`Lebensmittel gespeichert (${result.id}).`);
},
});
const addNutritionSourceMutation = trpc.health.addNutritionSource.useMutation({
onSuccess: async (result) => {
await utils.health.listFoods.invalidate();
setStatus(`Quelle gespeichert. Neuer Durchschnitt aus ${result.sourceCount} Quelle(n): ${result.average.kcalPer100g} kcal/100 g.`);
},
});
const setFoodPreferenceMutation = trpc.health.setFoodPreference.useMutation({
onSuccess: async () => {
await utils.health.listFoods.invalidate();
await utils.health.recipeRecommendations.invalidate();
},
});
const foods = foodsQuery.data ?? [];
function saveFood(source: "manual" | "openfoodfacts" | "ocr" = "manual") {
if (!form.name.trim()) return;
saveFoodMutation.mutate({
name: form.name.trim(),
brand: form.brand.trim() || null,
category: form.category.trim() || "Sonstiges",
kcalPer100g: toNumber(form.kcalPer100g),
proteinPer100g: toNumber(form.proteinPer100g),
carbsPer100g: toNumber(form.carbsPer100g),
fatPer100g: toNumber(form.fatPer100g),
servingG: toNumber(form.servingG) || 100,
preferredUnit: form.preferredUnit,
densityGPerMl: form.densityGPerMl.trim() ? toNumber(form.densityGPerMl) : null,
barcode: form.barcode.trim() || null,
source,
availableEverywhere: form.availableEverywhere,
portions: form.portionLabel.trim()
? [{
label: form.portionLabel.trim(),
unit: form.portionUnit,
amount: toNumber(form.portionAmount) || 1,
gramsEquivalent: toNumber(form.portionGramsEquivalent) || toNumber(form.servingG) || 100,
}]
: [],
});
}
async function importFromOpenFoodFacts() {
const cleanBarcode = barcode.replace(/\D/g, "");
if (!cleanBarcode) return;
setLookupBusy(true);
setStatus(null);
try {
const response = await fetch(`https://world.openfoodfacts.org/api/v2/product/${cleanBarcode}.json?fields=product_name,brands,categories,nutriments,serving_quantity`);
const result = await response.json() as {
status?: number;
product?: {
product_name?: string;
brands?: string;
categories?: string;
serving_quantity?: number;
nutriments?: Record<string, number>;
};
};
if (result.status !== 1 || !result.product) {
setStatus("Kein Produkt für diesen Barcode gefunden.");
return;
}
const nutriments = result.product.nutriments ?? {};
setForm(formFromFood({
name: result.product.product_name || "Unbenanntes Produkt",
brand: result.product.brands?.split(",")[0]?.trim(),
category: result.product.categories?.split(",")[0]?.trim() || "Supermarkt",
kcalPer100g: nutriments["energy-kcal_100g"] ?? 0,
proteinPer100g: nutriments.proteins_100g ?? 0,
carbsPer100g: nutriments.carbohydrates_100g ?? 0,
fatPer100g: nutriments.fat_100g ?? 0,
servingG: result.product.serving_quantity ?? 100,
barcode: cleanBarcode,
}));
setStatus("Open-Food-Facts-Daten geladen. Bitte prüfen und speichern.");
} catch {
setStatus("Open Food Facts konnte gerade nicht abgefragt werden.");
} finally {
setLookupBusy(false);
}
}
async function readLabelImage(file: File) {
setOcrBusy(true);
setStatus("OCR läuft im Browser. Das kann einen Moment dauern.");
try {
const { recognize } = await import("tesseract.js");
const result = await recognize(file, "deu+eng");
const text = result.data.text;
const parsed = parseNutritionLabel(text);
setOcrText(text);
setForm((current) => ({
...current,
...formFromFood({ ...parsed, name: current.name, brand: current.brand, category: current.category, servingG: current.servingG || "100" }),
}));
setStatus("OCR abgeschlossen. Werte bitte kontrollieren und speichern.");
} catch {
setStatus("OCR konnte nicht abgeschlossen werden. Du kannst den Labeltext unten einfügen.");
} finally {
setOcrBusy(false);
}
}
function parsePastedOcr() {
const parsed = parseNutritionLabel(ocrText);
setForm((current) => ({
...current,
...formFromFood({ ...parsed, name: current.name, brand: current.brand, category: current.category, servingG: current.servingG || "100" }),
}));
setStatus("Text ausgewertet. Werte bitte kontrollieren und speichern.");
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Health-API</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Lebensmittelkatalog</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Eigene Basisdaten, Supermarktprodukte, Barcode-Import und Label-OCR. Lebensmittel werden jetzt serverseitig pro Benutzer gespeichert.
</p>
</header>
<section className="grid gap-4 xl:grid-cols-[1fr_0.9fr]">
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex 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="Lebensmittel, Marke, Kategorie oder Barcode suchen"
className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none"
/>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">
{foods.map((food) => (
<article key={food.id} className="rounded-3xl bg-gray-50 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-lg font-black text-gray-950">{food.name}</p>
<p className="text-sm font-bold text-gray-400">{food.brand || food.category} - {Number(food.servingG)} g Portion</p>
</div>
{food.isVerified ? <CheckCircleIcon className="h-6 w-6 text-emerald-600" /> : null}
</div>
<div className="mt-4 grid grid-cols-4 gap-2 text-center">
<div><p className="text-xl font-black">{Number(food.kcalPer100g)}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
<div><p className="text-xl font-black">{Number(food.proteinPer100g)}</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
<div><p className="text-xl font-black">{Number(food.carbsPer100g)}</p><p className="text-xs font-bold text-gray-400">KH</p></div>
<div><p className="text-xl font-black">{Number(food.fatPer100g)}</p><p className="text-xs font-bold text-gray-400">Fett</p></div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full bg-white px-3 py-1 text-xs font-bold uppercase text-gray-300">{food.source}</span>
<span className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
{food.availableEverywhere ? "überall erhältlich" : "Marktzuordnung"}
</span>
{food.portions?.map((portion) => (
<span key={portion.id} className="rounded-full bg-teal-50 px-3 py-1 text-xs font-bold text-teal-700">
{portion.label}: {Number(portion.amount)} {unitLabel(portion.unit)} = {Number(portion.gramsEquivalent)} g
</span>
))}
</div>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => setFoodPreferenceMutation.mutate({ foodId: food.id, vote: food.userVote === 1 ? 0 : 1 })}
className={`inline-flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2 text-xs font-black ${
food.userVote === 1 ? "bg-emerald-600 text-white" : "bg-white text-emerald-700"
}`}
>
<HandThumbUpIcon className="h-4 w-4" />
Mag ich
</button>
<button
type="button"
onClick={() => setFoodPreferenceMutation.mutate({ foodId: food.id, vote: food.userVote === -1 ? 0 : -1 })}
className={`inline-flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2 text-xs font-black ${
food.userVote === -1 ? "bg-red-600 text-white" : "bg-white text-red-700"
}`}
>
<HandThumbDownIcon className="h-4 w-4" />
Nicht meins
</button>
</div>
<div className="mt-3 rounded-2xl bg-white p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-black uppercase text-gray-400">Quellen</p>
<button
type="button"
onClick={() => {
setSourceFoodId(sourceFoodId === food.id ? null : food.id);
setSourceQuery(food.name);
}}
className="inline-flex items-center gap-1 rounded-full bg-gray-950 px-3 py-1 text-xs font-black text-white"
>
<ScaleIcon className="h-4 w-4" />
Mittelwert
</button>
</div>
{food.nutritionSources?.length ? (
<div className="mt-2 space-y-1">
{food.nutritionSources.map((source) => (
<a
key={source.id}
href={source.url ?? "#"}
target="_blank"
rel="noreferrer"
className="block rounded-xl bg-gray-50 px-3 py-2 text-xs font-bold text-gray-500 hover:bg-gray-100"
>
{source.provider}: {Number(source.kcalPer100g)} kcal/100 g
</a>
))}
</div>
) : (
<p className="mt-2 text-xs font-bold text-gray-300">Noch keine einzelnen Quellen hinterlegt.</p>
)}
{sourceFoodId === food.id ? (
<div className="mt-3 space-y-2">
<input
value={sourceQuery}
onChange={(event) => setSourceQuery(event.target.value)}
placeholder="Open Food Facts suchen"
className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-semibold"
/>
{(sourceSearchQuery.data ?? []).map((source) => (
<button
key={`${source.provider}-${source.providerFoodId ?? source.name}`}
type="button"
onClick={() => addNutritionSourceMutation.mutate({ foodId: food.id, ...source })}
className="block w-full rounded-2xl bg-emerald-50 px-3 py-2 text-left text-xs font-bold text-emerald-900 hover:bg-emerald-100"
>
{source.name} · {source.brand || "ohne Marke"} · {source.kcalPer100g} kcal/100 g
</button>
))}
{sourceSearchQuery.isFetching ? <p className="text-xs font-bold text-gray-300">Suche läuft...</p> : null}
</div>
) : null}
</div>
</article>
))}
{!foods.length && !foodsQuery.isLoading ? (
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Noch keine Lebensmittel 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">Produkt importieren</h2>
<div className="mt-4 grid grid-cols-[1fr_52px] gap-2">
<input
value={barcode}
onChange={(event) => setBarcode(event.target.value)}
placeholder="Barcode / EAN"
className="rounded-full border border-gray-200 px-4 py-3 text-sm"
/>
<button
type="button"
onClick={() => void importFromOpenFoodFacts()}
disabled={lookupBusy}
className="grid place-items-center rounded-full bg-gray-950 text-white disabled:opacity-50"
aria-label="Barcode suchen"
title="Barcode suchen"
>
<ArrowPathIcon className="h-5 w-5" />
</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">Label per OCR</h2>
<label className="mt-4 flex cursor-pointer items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white">
<CameraIcon className="h-5 w-5" />
Foto auswählen
<input
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void readLabelImage(file);
event.currentTarget.value = "";
}}
/>
</label>
<textarea
value={ocrText}
onChange={(event) => setOcrText(event.target.value)}
placeholder="Oder OCR-/Labeltext hier einfügen..."
className="mt-3 min-h-28 w-full rounded-3xl border border-gray-200 px-4 py-3 text-sm"
/>
<button type="button" onClick={parsePastedOcr} disabled={!ocrText.trim() || ocrBusy} className="mt-3 w-full rounded-full bg-gray-100 px-4 py-3 text-sm font-black text-gray-950 disabled:opacity-40">
Text auswerten
</button>
</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">Lebensmittel speichern</h2>
<div className="mt-4 grid gap-3">
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Name" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.brand} onChange={(event) => setForm((current) => ({ ...current, brand: event.target.value }))} placeholder="Marke optional" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.category} onChange={(event) => setForm((current) => ({ ...current, category: event.target.value }))} placeholder="Kategorie" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<label className="flex items-center gap-3 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-black text-gray-950">
<input
type="checkbox"
checked={form.availableEverywhere}
onChange={(event) => setForm((current) => ({ ...current, availableEverywhere: event.target.checked }))}
/>
Überall erhältlich
</label>
<div className="grid grid-cols-2 gap-2">
<input value={form.kcalPer100g} onChange={(event) => setForm((current) => ({ ...current, kcalPer100g: event.target.value }))} placeholder="kcal/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.servingG} onChange={(event) => setForm((current) => ({ ...current, servingG: event.target.value }))} placeholder="Portion g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.proteinPer100g} onChange={(event) => setForm((current) => ({ ...current, proteinPer100g: event.target.value }))} placeholder="Protein/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.carbsPer100g} onChange={(event) => setForm((current) => ({ ...current, carbsPer100g: event.target.value }))} placeholder="KH/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.fatPer100g} onChange={(event) => setForm((current) => ({ ...current, fatPer100g: event.target.value }))} placeholder="Fett/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.barcode} onChange={(event) => setForm((current) => ({ ...current, barcode: event.target.value }))} placeholder="Barcode" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
</div>
<div className="rounded-3xl bg-gray-50 p-4">
<p className="text-sm font-black text-gray-950">Alltagseinheiten</p>
<div className="mt-3 grid grid-cols-2 gap-2">
<select value={form.preferredUnit} onChange={(event) => setForm((current) => ({ ...current, preferredUnit: event.target.value as PortionUnit }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
{portionUnits.map((portionUnit) => <option key={portionUnit} value={portionUnit}>Standard: {unitLabel(portionUnit)}</option>)}
</select>
<input value={form.densityGPerMl} onChange={(event) => setForm((current) => ({ ...current, densityGPerMl: event.target.value }))} placeholder="Dichte g/ml optional" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.portionLabel} onChange={(event) => setForm((current) => ({ ...current, portionLabel: event.target.value }))} placeholder="Portion, z. B. 1 Becher" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<select value={form.portionUnit} onChange={(event) => setForm((current) => ({ ...current, portionUnit: event.target.value as PortionUnit }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
{portionUnits.map((portionUnit) => <option key={portionUnit} value={portionUnit}>{unitLabel(portionUnit)}</option>)}
</select>
<input value={form.portionAmount} onChange={(event) => setForm((current) => ({ ...current, portionAmount: event.target.value }))} placeholder="Menge, z. B. 1" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.portionGramsEquivalent} onChange={(event) => setForm((current) => ({ ...current, portionGramsEquivalent: event.target.value }))} placeholder="entspricht g/ml-Basis" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
</div>
</div>
<button
type="button"
onClick={() => saveFood(form.barcode ? "openfoodfacts" : "manual")}
disabled={saveFoodMutation.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" />
In Katalog aufnehmen
</button>
</div>
</section>
{status ? <div className="rounded-3xl bg-emerald-50 px-5 py-4 text-sm font-bold text-emerald-800">{status}</div> : null}
{saveFoodMutation.error ? <div className="rounded-3xl bg-red-50 px-5 py-4 text-sm font-bold text-red-800">{saveFoodMutation.error.message}</div> : null}
</aside>
</section>
</div>
);
}

View file

@ -0,0 +1,335 @@
"use client";
import { ArrowTopRightOnSquareIcon, MapPinIcon, PlusIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { FormField, inputClassName } from "@/app/_components/form-field";
import { uid } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
type DepartmentDraft = {
id: string;
name: string;
sortOrder: number;
};
type StoreDraft = {
id?: string;
name: string;
address?: string | null;
placeId?: string | null;
latitude?: number | null;
longitude?: number | null;
favorite: boolean;
departments: DepartmentDraft[];
};
function defaultDepartments(storeId: string): DepartmentDraft[] {
return [
{ id: `${storeId}-produce`, name: "Obst & Gemüse", sortOrder: 1 },
{ id: `${storeId}-bakery`, name: "Brot & Backwaren", sortOrder: 2 },
{ id: `${storeId}-dairy`, name: "Milchprodukte", sortOrder: 3 },
{ id: `${storeId}-protein`, name: "Fleisch/Fisch/Protein", sortOrder: 4 },
{ id: `${storeId}-drinks`, name: "Getränke", sortOrder: 5 },
{ id: `${storeId}-other`, name: "Sonstiges", sortOrder: 99 },
];
}
function mapsSearchUrl(name: string, address?: string | null) {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([name, address].filter(Boolean).join(" "))}`;
}
export default function MaerktePage() {
const utils = trpc.useUtils();
const [storeName, setStoreName] = useState("");
const [storeAddress, setStoreAddress] = useState("");
const [storeLat, setStoreLat] = useState("");
const [storeLng, setStoreLng] = useState("");
const [departmentNames, setDepartmentNames] = useState<Record<string, string>>({});
const [changeNotes, setChangeNotes] = useState<Record<string, string>>({});
const [status, setStatus] = useState<string | null>(null);
const storesQuery = trpc.health.listStores.useQuery();
const requestsQuery = trpc.health.myStoreChangeRequests.useQuery();
const saveStoreMutation = trpc.health.saveStore.useMutation({
onSuccess: async () => {
await utils.health.listStores.invalidate();
setStatus("Markt gespeichert.");
},
});
const submitStoreChangeRequest = trpc.health.submitStoreChangeRequest.useMutation({
onSuccess: async () => {
await utils.health.myStoreChangeRequests.invalidate();
setStatus("Änderungsantrag eingereicht.");
},
});
const setStoreFavoriteMutation = trpc.health.setStoreFavorite.useMutation({
onSuccess: () => utils.health.listStores.invalidate(),
});
const deleteStoreMutation = trpc.health.deleteStore.useMutation({
onSuccess: () => utils.health.listStores.invalidate(),
});
const stores = useMemo(() => storesQuery.data ?? [], [storesQuery.data]);
function saveDraft(draft: StoreDraft) {
saveStoreMutation.mutate({
id: draft.id,
name: draft.name,
address: draft.address || null,
placeId: draft.placeId || null,
latitude: draft.latitude ?? null,
longitude: draft.longitude ?? null,
favorite: draft.favorite,
departments: draft.departments
.map((department, index) => ({
id: department.id,
name: department.name,
sortOrder: department.sortOrder || index + 1,
}))
.sort((a, b) => a.sortOrder - b.sortOrder),
});
}
function addStore() {
if (!storeName.trim()) return;
const storeId = uid("store");
saveDraft({
id: storeId,
name: storeName.trim(),
address: storeAddress.trim() || null,
latitude: storeLat ? Number(storeLat.replace(",", ".")) : null,
longitude: storeLng ? Number(storeLng.replace(",", ".")) : null,
favorite: false,
departments: defaultDepartments(storeId),
});
setStoreName("");
setStoreAddress("");
setStoreLat("");
setStoreLng("");
}
function useCurrentLocation() {
if (!navigator.geolocation) {
setStatus("Standortfreigabe ist in diesem Browser nicht verfügbar.");
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
setStoreLat(position.coords.latitude.toFixed(6));
setStoreLng(position.coords.longitude.toFixed(6));
setStatus("Standort übernommen. Bitte Marktname und Adresse ergänzen.");
},
() => setStatus("Standort konnte nicht gelesen werden."),
{ enableHighAccuracy: true, timeout: 10000 },
);
}
function toggleFavorite(store: StoreDraft) {
if (!store.id) return;
setStoreFavoriteMutation.mutate({ storeId: store.id, favorite: !store.favorite });
}
function submitChange(store: StoreDraft, requestType: "new_store" | "structure_update" | "location_update" = "structure_update") {
submitStoreChangeRequest.mutate({
storeId: store.id ?? null,
requestType,
title: `${store.name}: Änderung übernehmen`,
note: changeNotes[store.id ?? store.name] || null,
store: {
name: store.name,
address: store.address ?? null,
placeId: store.placeId ?? null,
latitude: store.latitude ?? null,
longitude: store.longitude ?? null,
},
departments: store.departments,
});
}
function addDepartment(store: StoreDraft) {
const name = departmentNames[store.id ?? store.name]?.trim();
if (!name) return;
const maxOrder = Math.max(0, ...store.departments.map((department) => department.sortOrder));
saveDraft({
...store,
departments: [
...store.departments,
{ id: uid(`${store.id ?? "store"}-dept`), name, sortOrder: maxOrder + 1 },
],
});
setDepartmentNames((current) => ({ ...current, [store.id ?? store.name]: "" }));
}
function removeDepartment(store: StoreDraft, departmentId: string) {
if (store.departments.length <= 1) return;
saveDraft({ ...store, departments: store.departments.filter((department) => department.id !== departmentId) });
}
function moveDepartment(store: StoreDraft, departmentId: string, direction: -1 | 1) {
const sorted = [...store.departments].sort((a, b) => a.sortOrder - b.sortOrder);
const index = sorted.findIndex((department) => department.id === departmentId);
const targetIndex = index + direction;
if (index < 0 || targetIndex < 0 || targetIndex >= sorted.length) return;
const current = sorted[index]!;
const target = sorted[targetIndex]!;
sorted[index] = { ...current, sortOrder: target.sortOrder };
sorted[targetIndex] = { ...target, sortOrder: current.sortOrder };
saveDraft({ ...store, departments: sorted });
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Einkaufen</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Märkte & Laufwege</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Speichere nahe Lebensmittelläden, pinne ihren Standort und sortiere die Abteilungen so, wie du wirklich durch den Markt läufst.
</p>
</header>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="grid gap-3 lg:grid-cols-[1fr_1.4fr_120px_120px_130px]">
<FormField label="Marktname">
<input value={storeName} onChange={(event) => setStoreName(event.target.value)} placeholder="z. B. Kaufland" className={inputClassName} />
</FormField>
<FormField label="Adresse">
<input value={storeAddress} onChange={(event) => setStoreAddress(event.target.value)} placeholder="Straße, Ort" className={inputClassName} />
</FormField>
<FormField label="Breite">
<input value={storeLat} onChange={(event) => setStoreLat(event.target.value)} placeholder="48.3705" className={inputClassName} />
</FormField>
<FormField label="Länge">
<input value={storeLng} onChange={(event) => setStoreLng(event.target.value)} placeholder="10.8978" className={inputClassName} />
</FormField>
<div className="flex items-end gap-2">
<button type="button" onClick={useCurrentLocation} className="grid h-12 w-12 place-items-center rounded-2xl bg-gray-100 text-gray-700" aria-label="Standort übernehmen" title="Standort übernehmen">
<MapPinIcon className="h-5 w-5" />
</button>
<button type="button" onClick={addStore} className="grid h-12 w-12 place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Markt speichern" title="Markt speichern">
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="mt-3">
<a href={mapsSearchUrl(storeName || "Supermarkt", storeAddress)} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-full bg-teal-50 px-4 py-2 text-xs font-black text-[#0F766E]">
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
In Google Maps suchen
</a>
</div>
</section>
<div className="grid gap-4 lg:grid-cols-2">
{stores.map((store) => {
const canEdit = store.ownerUserId !== null;
const draft: StoreDraft = {
id: store.id,
name: store.name,
address: store.address,
placeId: store.placeId,
latitude: store.latitude,
longitude: store.longitude,
favorite: store.favorite,
departments: store.departments.map((department) => ({
id: department.id,
name: department.name,
sortOrder: department.sortOrder,
})),
};
const departmentKey = store.id;
return (
<section key={store.id} className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-2xl font-black text-gray-950">{store.name}</h2>
<p className="text-sm font-bold text-gray-400">
{store.departments.length} Abteilungen
{!canEdit ? " · globale Vorlage" : " · eigener Markt"}
</p>
{store.address ? <p className="mt-1 text-sm font-bold text-gray-500">{store.address}</p> : null}
{store.latitude != null && store.longitude != null ? (
<a href={`https://www.google.com/maps/search/?api=1&query=${store.latitude},${store.longitude}`} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs font-black text-[#0F766E]">
<MapPinIcon className="h-4 w-4" />
Pin öffnen
</a>
) : null}
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => toggleFavorite(draft)} className={`rounded-full p-2 ${store.favorite ? "bg-amber-100 text-amber-600" : "bg-gray-100 text-gray-300"}`} aria-label="Favorit" title="Favorit">
<StarIcon className="h-5 w-5" />
</button>
{canEdit ? (
<button type="button" onClick={() => deleteStoreMutation.mutate({ id: store.id })} className="rounded-full p-2 text-red-500 hover:bg-red-50" aria-label="Markt löschen" title="Markt löschen">
<TrashIcon className="h-5 w-5" />
</button>
) : null}
</div>
</div>
<div className="mt-5 space-y-2">
{[...store.departments].sort((a, b) => a.sortOrder - b.sortOrder).map((department, index) => (
<div key={department.id} className="grid grid-cols-[40px_1fr_96px_40px] items-center gap-2 rounded-2xl bg-gray-50 px-3 py-2">
<span className="grid h-8 w-8 place-items-center rounded-full bg-white text-sm font-black text-gray-400">{index + 1}</span>
<span className="text-sm font-black text-gray-950">{department.name}</span>
<div className="flex gap-1">
{canEdit ? (
<>
<button type="button" onClick={() => moveDepartment(draft, department.id, -1)} className="rounded-full bg-white px-2 py-1 text-xs font-black text-gray-500">hoch</button>
<button type="button" onClick={() => moveDepartment(draft, department.id, 1)} className="rounded-full bg-white px-2 py-1 text-xs font-black text-gray-500">runter</button>
</>
) : null}
</div>
{canEdit ? (
<button type="button" onClick={() => removeDepartment(draft, department.id)} className="rounded-full p-2 text-red-500 hover:bg-red-50" aria-label="Abteilung löschen" title="Abteilung löschen">
<TrashIcon className="h-4 w-4" />
</button>
) : <span />}
</div>
))}
</div>
{canEdit ? (
<div className="mt-4 grid grid-cols-[1fr_44px] gap-2">
<input
value={departmentNames[departmentKey] ?? ""}
onChange={(event) => setDepartmentNames((current) => ({ ...current, [departmentKey]: event.target.value }))}
placeholder="Neue Abteilung"
className={inputClassName}
/>
<button type="button" onClick={() => addDepartment(draft)} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Abteilung hinzufügen" title="Abteilung hinzufügen">
<PlusIcon className="h-5 w-5" />
</button>
</div>
) : null}
<div className="mt-4 rounded-3xl bg-gray-50 p-4">
<FormField label="Änderung einreichen" hint={canEdit ? "Eigene Märkte können vom Admin als globale Vorlage übernommen werden." : "Globale Vorlagen werden nach Admin-Prüfung aktualisiert."}>
<textarea value={changeNotes[departmentKey] ?? ""} onChange={(event) => setChangeNotes((current) => ({ ...current, [departmentKey]: event.target.value }))} placeholder="Was hat sich im Markt geändert?" className={`${inputClassName} min-h-20`} />
</FormField>
<button type="button" onClick={() => submitChange(draft)} className="mt-3 w-full rounded-full bg-[#0F766E] px-4 py-3 text-sm font-black text-white">
Änderung zur Prüfung einreichen
</button>
</div>
</section>
);
})}
</div>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Meine Änderungsanträge</h2>
<div className="mt-3 grid gap-2">
{(requestsQuery.data ?? []).slice(0, 8).map((request) => (
<div key={request.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-bold">
<span className="min-w-0 truncate text-gray-950">{request.title}</span>
<span className={`rounded-full px-3 py-1 text-xs ${request.status === "accepted" ? "bg-emerald-100 text-emerald-700" : request.status === "rejected" ? "bg-red-100 text-red-700" : "bg-amber-100 text-amber-700"}`}>
{request.status === "accepted" ? "übernommen" : request.status === "rejected" ? "abgelehnt" : "in Prüfung"}
</span>
</div>
))}
{!(requestsQuery.data ?? []).length ? <p className="text-sm font-bold text-gray-400">Noch keine Änderungsanträge.</p> : null}
</div>
</section>
{status ? <div className="rounded-3xl bg-emerald-50 px-5 py-4 text-sm font-bold text-emerald-800">{status}</div> : null}
</div>
);
}

View file

@ -0,0 +1,316 @@
"use client";
import {
ArrowRightIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
SparklesIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useSession } from "@/lib/auth-client";
import {
ActivityLevel,
HealthGoal,
HealthProfile,
WeightLossPace,
buildCoachRecommendations,
buildGoalsFromProfile,
loadHellthData,
saveHellthData,
} from "@/lib/hellth-data";
type FormState = {
goal: HealthGoal;
age: string;
heightCm: string;
currentWeightKg: string;
targetWeightKg: string;
activityLevel: ActivityLevel;
pace: WeightLossPace;
mealsPerDay: string;
dietStyle: HealthProfile["dietStyle"];
cookingTime: HealthProfile["cookingTime"];
budgetFocus: boolean;
excludedFoods: string;
biggestChallenge: string;
medicalNote: boolean;
};
const initialForm: FormState = {
goal: "lose_weight",
age: "",
heightCm: "",
currentWeightKg: "",
targetWeightKg: "",
activityLevel: "moderate",
pace: "normal",
mealsPerDay: "3",
dietStyle: "mixed",
cookingTime: "medium",
budgetFocus: false,
excludedFoods: "",
biggestChallenge: "",
medicalNote: false,
};
function numberFrom(value: string) {
return Number(value.replace(",", ".")) || 0;
}
function OptionButton({
active,
label,
description,
onClick,
}: {
active: boolean;
label: string;
description: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-3xl border p-4 text-left transition ${
active ? "border-[#0F766E] bg-emerald-50 ring-2 ring-emerald-100" : "border-gray-100 bg-white hover:bg-gray-50"
}`}
>
<p className="text-base font-black text-gray-950">{label}</p>
<p className="mt-1 text-sm font-semibold text-gray-400">{description}</p>
</button>
);
}
export default function OnboardingPage() {
const router = useRouter();
const { data: session } = useSession();
const userKey = session?.user?.id ?? session?.user?.email ?? null;
const [form, setForm] = useState<FormState>(initialForm);
const [savedProfile, setSavedProfile] = useState<HealthProfile | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!userKey) return;
const profile = loadHellthData(userKey).healthProfile;
if (profile) {
setSavedProfile(profile);
setForm({
goal: profile.goal,
age: String(profile.age),
heightCm: String(profile.heightCm),
currentWeightKg: String(profile.currentWeightKg),
targetWeightKg: profile.targetWeightKg ? String(profile.targetWeightKg) : "",
activityLevel: profile.activityLevel,
pace: profile.pace,
mealsPerDay: String(profile.mealsPerDay),
dietStyle: profile.dietStyle,
cookingTime: profile.cookingTime,
budgetFocus: profile.budgetFocus,
excludedFoods: profile.excludedFoods,
biggestChallenge: profile.biggestChallenge,
medicalNote: profile.medicalNote,
});
}
}, [userKey]);
function saveProfile() {
if (!userKey) return;
const age = numberFrom(form.age);
const heightCm = numberFrom(form.heightCm);
const currentWeightKg = numberFrom(form.currentWeightKg);
const targetWeightKg = numberFrom(form.targetWeightKg);
if (age < 16 || heightCm < 120 || currentWeightKg < 35) {
setError("Bitte Alter, Größe und aktuelles Gewicht plausibel ausfüllen.");
return;
}
if (form.goal === "lose_weight" && targetWeightKg > 0 && targetWeightKg >= currentWeightKg) {
setError("Für Abnehmen sollte das Zielgewicht unter dem aktuellen Gewicht liegen.");
return;
}
const profile: HealthProfile = {
completedAt: new Date().toISOString(),
goal: form.goal,
age,
heightCm,
currentWeightKg,
targetWeightKg: targetWeightKg || undefined,
activityLevel: form.activityLevel,
pace: form.pace,
mealsPerDay: Math.max(1, Math.min(6, numberFrom(form.mealsPerDay) || 3)),
dietStyle: form.dietStyle,
cookingTime: form.cookingTime,
budgetFocus: form.budgetFocus,
excludedFoods: form.excludedFoods.trim(),
biggestChallenge: form.biggestChallenge.trim(),
medicalNote: form.medicalNote,
};
const goals = buildGoalsFromProfile(profile);
const coachRecommendations = buildCoachRecommendations(profile, goals);
const data = loadHellthData(userKey);
saveHellthData({ ...data, healthProfile: profile, goals, coachRecommendations }, userKey);
setSavedProfile(profile);
setError(null);
}
const previewProfile: HealthProfile | null =
numberFrom(form.age) >= 16 && numberFrom(form.heightCm) >= 120 && numberFrom(form.currentWeightKg) >= 35
? {
completedAt: new Date().toISOString(),
goal: form.goal,
age: numberFrom(form.age),
heightCm: numberFrom(form.heightCm),
currentWeightKg: numberFrom(form.currentWeightKg),
targetWeightKg: numberFrom(form.targetWeightKg) || undefined,
activityLevel: form.activityLevel,
pace: form.pace,
mealsPerDay: numberFrom(form.mealsPerDay) || 3,
dietStyle: form.dietStyle,
cookingTime: form.cookingTime,
budgetFocus: form.budgetFocus,
excludedFoods: form.excludedFoods,
biggestChallenge: form.biggestChallenge,
medicalNote: form.medicalNote,
}
: null;
const previewGoals = previewProfile ? buildGoalsFromProfile(previewProfile) : null;
const previewRecommendations = previewProfile && previewGoals ? buildCoachRecommendations(previewProfile, previewGoals) : [];
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-[#0F766E] text-white">
<SparklesIcon className="h-7 w-7" />
</div>
<div>
<p className="text-sm font-black uppercase text-[#0F766E]">Erster Start</p>
<h1 className="text-4xl font-black tracking-normal text-gray-950">Dein Health-Profil</h1>
</div>
</div>
<p className="mt-4 max-w-3xl text-sm font-semibold text-gray-500">
Hellth Hub nutzt diese Antworten, um alltagstaugliche Ziele und konkrete Vorschläge zu erzeugen. Die Werte sind Startpunkte und werden später über deinen echten Verlauf verbessert.
</p>
</header>
<section className="grid gap-5 xl:grid-cols-[1fr_0.8fr]">
<div className="space-y-5">
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">1. Ziel</h2>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<OptionButton active={form.goal === "lose_weight"} label="Abnehmen" description="Fokus auf moderates Defizit und Sättigung." onClick={() => setForm((current) => ({ ...current, goal: "lose_weight" }))} />
<OptionButton active={form.goal === "maintain"} label="Gewicht halten" description="Routine, Energie und Stabilität verbessern." onClick={() => setForm((current) => ({ ...current, goal: "maintain" }))} />
<OptionButton active={form.goal === "healthier"} label="Gesünder leben" description="Mehr Struktur, bessere Lebensmittelauswahl." onClick={() => setForm((current) => ({ ...current, goal: "healthier" }))} />
</div>
</div>
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">2. Ausgangslage</h2>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<input value={form.age} onChange={(event) => setForm((current) => ({ ...current, age: event.target.value }))} placeholder="Alter" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<input value={form.heightCm} onChange={(event) => setForm((current) => ({ ...current, heightCm: event.target.value }))} placeholder="Größe cm" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<input value={form.currentWeightKg} onChange={(event) => setForm((current) => ({ ...current, currentWeightKg: event.target.value }))} placeholder="Aktuelles Gewicht kg" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<input value={form.targetWeightKg} onChange={(event) => setForm((current) => ({ ...current, targetWeightKg: event.target.value }))} placeholder="Zielgewicht kg" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<OptionButton active={form.activityLevel === "low"} label="Wenig aktiv" description="Viel Sitzen, wenig Bewegung." onClick={() => setForm((current) => ({ ...current, activityLevel: "low" }))} />
<OptionButton active={form.activityLevel === "moderate"} label="Moderat" description="Alltag mit etwas Bewegung." onClick={() => setForm((current) => ({ ...current, activityLevel: "moderate" }))} />
<OptionButton active={form.activityLevel === "active"} label="Aktiv" description="Regelmäßiges Training oder aktiver Job." onClick={() => setForm((current) => ({ ...current, activityLevel: "active" }))} />
</div>
</div>
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">3. Alltag</h2>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<OptionButton active={form.pace === "gentle"} label="Sanft" description="Kleines Defizit, leichter Alltag." onClick={() => setForm((current) => ({ ...current, pace: "gentle" }))} />
<OptionButton active={form.pace === "normal"} label="Normal" description="Guter Startpunkt für Abnehmen." onClick={() => setForm((current) => ({ ...current, pace: "normal" }))} />
<OptionButton active={form.pace === "ambitious"} label="Ambitioniert" description="Mehr Struktur, genauer beobachten." onClick={() => setForm((current) => ({ ...current, pace: "ambitious" }))} />
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<input value={form.mealsPerDay} onChange={(event) => setForm((current) => ({ ...current, mealsPerDay: event.target.value }))} placeholder="Mahlzeiten pro Tag" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<select value={form.dietStyle} onChange={(event) => setForm((current) => ({ ...current, dietStyle: event.target.value as FormState["dietStyle"] }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
<option value="mixed">Mischkost</option>
<option value="flexible">Flexibel</option>
<option value="vegetarian">Vegetarisch</option>
<option value="vegan">Vegan</option>
<option value="low_carb">Low Carb</option>
</select>
<select value={form.cookingTime} onChange={(event) => setForm((current) => ({ ...current, cookingTime: event.target.value as FormState["cookingTime"] }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
<option value="low">Wenig Kochzeit</option>
<option value="medium">Mittlere Kochzeit</option>
<option value="high">Koche gern</option>
</select>
<label className="flex items-center gap-3 rounded-2xl border border-gray-200 px-4 py-3 text-sm font-black">
<input type="checkbox" checked={form.budgetFocus} onChange={(event) => setForm((current) => ({ ...current, budgetFocus: event.target.checked }))} />
Budget wichtig
</label>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<textarea value={form.excludedFoods} onChange={(event) => setForm((current) => ({ ...current, excludedFoods: event.target.value }))} placeholder="Allergien, Ausschlüsse, No-Gos" className="min-h-28 rounded-3xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<textarea value={form.biggestChallenge} onChange={(event) => setForm((current) => ({ ...current, biggestChallenge: event.target.value }))} placeholder="Was macht Abnehmen oder gesünder essen aktuell schwer?" className="min-h-28 rounded-3xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
</div>
<label className="mt-4 flex items-start gap-3 rounded-3xl bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-900">
<input type="checkbox" checked={form.medicalNote} onChange={(event) => setForm((current) => ({ ...current, medicalNote: event.target.checked }))} className="mt-1" />
Es gibt Diagnosen, Medikamente, Schwangerschaft, Essstörungs-Vorgeschichte oder andere gesundheitliche Themen, die beachtet werden sollten.
</label>
</div>
</div>
<aside className="space-y-4">
<section className="sticky top-6 rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Auswertung</h2>
{previewGoals ? (
<>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.calories}</p><p className="text-xs font-bold text-gray-400">kcal/Tag</p></div>
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.protein} g</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.drinkMl}</p><p className="text-xs font-bold text-gray-400">ml Wasser</p></div>
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.weightKg}</p><p className="text-xs font-bold text-gray-400">kg Ziel</p></div>
</div>
<div className="mt-5 space-y-3">
{previewRecommendations.slice(0, 4).map((item) => (
<div key={item.id} className="rounded-3xl bg-emerald-50 p-4">
<p className="font-black text-emerald-950">{item.title}</p>
<p className="mt-1 text-sm font-semibold text-emerald-800">{item.body}</p>
</div>
))}
</div>
</>
) : (
<p className="mt-3 text-sm font-semibold text-gray-400">Fülle Alter, Größe und Gewicht aus, dann siehst du hier die ersten Vorschläge.</p>
)}
{error ? (
<div className="mt-4 flex gap-2 rounded-3xl bg-red-50 p-4 text-sm font-semibold text-red-700">
<ExclamationTriangleIcon className="h-5 w-5 shrink-0" />
{error}
</div>
) : null}
{savedProfile ? (
<div className="mt-4 flex gap-2 rounded-3xl bg-green-50 p-4 text-sm font-semibold text-green-700">
<CheckCircleIcon className="h-5 w-5 shrink-0" />
Profil gespeichert. Deine Ziele und Coach-Vorschläge sind jetzt userbezogen aktiv.
</div>
) : null}
<button type="button" onClick={saveProfile} className="mt-5 flex w-full items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-4 text-sm font-black text-white">
Profil speichern
<ArrowRightIcon className="h-5 w-5" />
</button>
{savedProfile ? (
<Link href="/" className="mt-3 flex w-full items-center justify-center rounded-full bg-emerald-600 px-5 py-4 text-sm font-black text-white">
Zur Tagesansicht
</Link>
) : null}
</section>
</aside>
</section>
</div>
);
}

View file

@ -0,0 +1,757 @@
"use client";
import {
BeakerIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
EllipsisHorizontalIcon,
FireIcon,
PlusIcon,
ScaleIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { trpc } from "@/lib/trpc";
type MealType = "Frühstück" | "Mittagessen" | "Abendessen" | "Snack";
type ApiMealType = "breakfast" | "lunch" | "dinner" | "snack";
type FoodEntry = {
id: string;
mealType: MealType;
name: string;
amount: string;
calories: number;
protein: number;
};
type DrinkEntry = {
id: string;
amountMl: number;
};
type PlannedMeal = {
id: string;
mealType: MealType;
name: string;
note?: string;
portions?: number;
plannedCalories?: number;
};
type DayData = {
foods: FoodEntry[];
drinks: DrinkEntry[];
weightKg?: number;
plannedMeals: PlannedMeal[];
};
const mealTypes: MealType[] = ["Frühstück", "Mittagessen", "Abendessen", "Snack"];
const mealTypeToApi: Record<MealType, ApiMealType> = {
Frühstück: "breakfast",
Mittagessen: "lunch",
Abendessen: "dinner",
Snack: "snack",
};
const mealTypeFromApi: Record<ApiMealType, MealType> = {
breakfast: "Frühstück",
lunch: "Mittagessen",
dinner: "Abendessen",
snack: "Snack",
};
const fallbackGoals = {
drinkMl: 2500,
calories: 2000,
carbs: 200,
fat: 67,
protein: 150,
fastingHours: 12,
};
function toDateKey(date: Date) {
return date.toISOString().slice(0, 10);
}
function parseDateKey(key: string) {
return new Date(`${key}T12:00:00`);
}
function addDays(key: string, delta: number) {
const date = parseDateKey(key);
date.setDate(date.getDate() + delta);
return toDateKey(date);
}
function emptyDay(): DayData {
return {
foods: [],
drinks: [],
plannedMeals: [],
};
}
function formatDate(key: string) {
return parseDateKey(key).toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
});
}
function progress(value: number, goal: number) {
if (goal <= 0) return 0;
return Math.min(100, Math.round((value / goal) * 100));
}
function foodSwatch(name: string) {
const colors = [
"from-amber-100 to-orange-200 text-orange-700",
"from-emerald-100 to-lime-200 text-emerald-700",
"from-sky-100 to-blue-200 text-blue-700",
"from-rose-100 to-pink-200 text-rose-700",
];
const index = name.length % colors.length;
return colors[index] ?? colors[0];
}
function RecipeAvatar({ name }: { name: string }) {
const initials = name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
return (
<div className={`flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-gradient-to-br ${foodSwatch(name)} text-lg font-black shadow-inner`}>
{initials || "HH"}
</div>
);
}
function WeekStrip({
selectedDate,
onSelect,
}: {
selectedDate: string;
onSelect: (date: string) => void;
}) {
const days = useMemo(() => {
const selected = parseDateKey(selectedDate);
const day = selected.getDay() === 0 ? 7 : selected.getDay();
const monday = new Date(selected);
monday.setDate(selected.getDate() - day + 1);
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(monday);
date.setDate(monday.getDate() + index);
return toDateKey(date);
});
}, [selectedDate]);
return (
<div className="grid grid-cols-7 gap-2">
{days.map((dateKey) => {
const date = parseDateKey(dateKey);
const active = dateKey === selectedDate;
return (
<button
type="button"
key={dateKey}
onClick={() => onSelect(dateKey)}
className={`flex min-h-16 flex-col items-center justify-center rounded-2xl text-sm transition ${
active
? "bg-white text-gray-950 shadow-sm ring-1 ring-gray-100"
: "text-gray-400 hover:bg-white/60"
}`}
>
<span className="font-semibold uppercase">{date.toLocaleDateString("de-DE", { weekday: "narrow" })}</span>
<span className="text-xl font-black">{date.getDate()}</span>
</button>
);
})}
</div>
);
}
function MacroBar({ carbs, fat, protein }: { carbs: number; fat: number; protein: number }) {
const total = Math.max(1, carbs + fat + protein);
return (
<div className="flex h-3 overflow-hidden rounded-full bg-gray-100">
<div className="bg-amber-400" style={{ width: `${(carbs / total) * 100}%` }} />
<div className="bg-blue-500" style={{ width: `${(fat / total) * 100}%` }} />
<div className="bg-green-500" style={{ width: `${(protein / total) * 100}%` }} />
</div>
);
}
function WaterGlasses({
amount,
goal,
onAdd,
locked,
}: {
amount: number;
goal: number;
onAdd: () => void;
locked: boolean;
}) {
const glasses = Math.max(6, Math.ceil(goal / 250));
const filled = Math.min(glasses, Math.floor(amount / 250));
return (
<div className="mt-6 grid grid-cols-6 gap-3 sm:grid-cols-10">
{Array.from({ length: glasses }, (_, index) => {
const isFilled = index < filled;
const isNext = index === filled && !locked;
return (
<button
type="button"
key={index}
disabled={locked || (!isNext && !isFilled)}
onClick={isNext ? onAdd : undefined}
className={`relative h-16 rounded-b-2xl rounded-t-md border border-blue-50 ${
isFilled ? "bg-blue-500" : "bg-blue-50"
} ${isNext ? "text-gray-950" : "text-transparent"} disabled:cursor-default`}
aria-label={isNext ? "250 ml Wasser hinzufügen" : undefined}
title={isNext ? "250 ml Wasser hinzufügen" : undefined}
>
<span className="absolute left-3 top-2 h-9 w-2 rounded-full bg-white/35" />
{isNext ? <PlusIcon className="absolute inset-0 m-auto h-6 w-6" /> : null}
</button>
);
})}
</div>
);
}
function PlanList({
meals,
onRemove,
canRemove,
}: {
meals: PlannedMeal[];
onRemove: (id: string) => void;
canRemove: boolean;
}) {
if (meals.length === 0) {
return (
<div className="mt-5 rounded-3xl bg-gray-50 p-5 text-sm font-bold text-gray-400">
Noch keine Mahlzeit geplant.
</div>
);
}
return (
<div className="mt-5 space-y-4">
{meals.map((meal) => (
<div key={meal.id} className="flex items-center gap-4 border-b border-gray-100 pb-4 last:border-0 last:pb-0">
<RecipeAvatar name={meal.name} />
<div className="min-w-0 flex-1">
<p className="truncate text-lg font-black text-gray-950">{meal.name}</p>
<p className="text-sm font-semibold text-gray-400">
{meal.mealType}
{meal.portions ? ` - ${meal.portions.toLocaleString("de-DE")} Portion` : ""}
{meal.plannedCalories ? ` - ${meal.plannedCalories.toLocaleString("de-DE")} kcal` : ""}
{meal.note ? ` - ${meal.note}` : ""}
</p>
</div>
{canRemove ? (
<button
type="button"
onClick={() => onRemove(meal.id)}
className="rounded-full bg-gray-100 p-2 text-gray-500 hover:text-red-600"
aria-label="Planung löschen"
title="Planung löschen"
>
<TrashIcon className="h-4 w-4" />
</button>
) : null}
</div>
))}
</div>
);
}
export default function DashboardPage() {
const utils = trpc.useUtils();
const todayKey = toDateKey(new Date());
const [selectedDate, setSelectedDate] = useState(todayKey);
const [quickMode, setQuickMode] = useState<"meal" | "plan" | "drink" | "weight">("meal");
const [foodForm, setFoodForm] = useState({
mealType: "Frühstück" as MealType,
name: "",
amount: "",
calories: "",
protein: "",
});
const [weightForm, setWeightForm] = useState("");
const [planForm, setPlanForm] = useState({
mealType: "Mittagessen" as MealType,
name: "",
note: "",
portions: "1",
plannedCalories: "",
});
const overviewQuery = trpc.health.overview.useQuery({ date: selectedDate });
const statsSummaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: todayKey });
const weeklyBudget = statsSummaryQuery.data?.week ?? null;
const coachRecommendations = overviewQuery.data?.coachRecommendations ?? [];
function invalidateDay() {
utils.health.overview.invalidate({ date: selectedDate });
utils.health.statsSummary.invalidate({ referenceDate: todayKey });
}
const addMealMutation = trpc.health.addMealLog.useMutation({ onSuccess: invalidateDay });
const addDrinkMutation = trpc.health.addDrinkLog.useMutation({ onSuccess: invalidateDay });
const saveWeightMutation = trpc.health.upsertWeightLog.useMutation({
onSuccess: () => {
invalidateDay();
utils.health.weightHistory.invalidate();
},
});
const saveMealPlanMutation = trpc.health.upsertMealPlan.useMutation({ onSuccess: invalidateDay });
const deleteMealMutation = trpc.health.deleteMealLog.useMutation({ onSuccess: invalidateDay });
const deletePlanMutation = trpc.health.deleteMealPlan.useMutation({ onSuccess: invalidateDay });
const goals = overviewQuery.data?.goals
? {
drinkMl: overviewQuery.data.goals.drinkMl,
calories: overviewQuery.data.goals.calories,
carbs: Number(overviewQuery.data.goals.carbsG) || fallbackGoals.carbs,
fat: Number(overviewQuery.data.goals.fatG) || fallbackGoals.fat,
protein: Number(overviewQuery.data.goals.proteinG) || fallbackGoals.protein,
fastingHours: overviewQuery.data.goals.fastingHours,
}
: fallbackGoals;
const day: DayData = overviewQuery.data?.day
? {
foods: overviewQuery.data.day.mealLogs.map((meal) => ({
id: meal.id,
mealType: mealTypeFromApi[meal.mealType as ApiMealType] ?? "Snack",
name: meal.name,
amount: meal.amountLabel ?? "1 Portion",
calories: meal.calories,
protein: Number(meal.proteinG) || 0,
})),
drinks: overviewQuery.data.day.drinkLogs.map((drink) => ({
id: drink.id,
amountMl: drink.amountMl,
})),
weightKg: overviewQuery.data.day.weightLog ? Number(overviewQuery.data.day.weightLog.weightKg) : undefined,
plannedMeals: overviewQuery.data.day.mealPlans.map((meal) => ({
id: meal.id,
mealType: mealTypeFromApi[meal.mealType as ApiMealType] ?? "Snack",
name: meal.name,
note: meal.note ?? undefined,
portions: Number(meal.portions) || 1,
plannedCalories: Number(meal.plannedCalories) || 0,
})),
}
: emptyDay();
const isFuture = selectedDate > todayKey;
const isToday = selectedDate === todayKey;
const isEditable = selectedDate <= todayKey;
const totals = useMemo(() => {
const calories = day.foods.reduce((sum, food) => sum + (Number(food.calories) || 0), 0);
const protein = day.foods.reduce((sum, food) => sum + (Number(food.protein) || 0), 0);
const drinkMl = day.drinks.reduce((sum, drink) => sum + (Number(drink.amountMl) || 0), 0);
const carbs = Math.round((calories * 0.45) / 4);
const fat = Math.round((calories * 0.3) / 9);
return { calories, protein, drinkMl, carbs, fat };
}, [day.drinks, day.foods]);
const remainingCalories = Math.max(0, goals.calories - totals.calories);
const nutritionScore = Math.min(100, Math.round((progress(totals.protein, goals.protein) + progress(totals.drinkMl, goals.drinkMl)) / 2));
function addFoodEntry() {
if (!isEditable || !foodForm.name.trim()) return;
addMealMutation.mutate({
date: selectedDate,
mealType: mealTypeToApi[foodForm.mealType],
name: foodForm.name.trim(),
amountLabel: foodForm.amount.trim() || "1 Portion",
calories: Number(foodForm.calories) || 0,
proteinG: Number(foodForm.protein) || 0,
carbsG: 0,
fatG: 0,
});
setFoodForm((current) => ({ ...current, name: "", amount: "", calories: "", protein: "" }));
}
function addDrinkEntry() {
if (!isEditable) return;
addDrinkMutation.mutate({ date: selectedDate, amountMl: 250 });
}
function saveWeight() {
if (!isEditable) return;
const weightKg = Number(weightForm);
if (!weightKg || weightKg <= 0) return;
saveWeightMutation.mutate({ date: selectedDate, weightKg, note: null });
setWeightForm("");
}
function addPlannedMeal() {
if (!planForm.name.trim()) return;
saveMealPlanMutation.mutate({
date: selectedDate,
mealType: mealTypeToApi[planForm.mealType],
name: planForm.name.trim(),
portions: Number(planForm.portions) || 1,
plannedCalories: Number(planForm.plannedCalories) || 0,
note: planForm.note.trim() || null,
recipeId: null,
});
setPlanForm((current) => ({ ...current, name: "", note: "", portions: "1", plannedCalories: "" }));
}
function removeEntry(kind: "foods" | "plannedMeals", id: string) {
if (kind === "foods") deleteMealMutation.mutate({ id });
if (kind === "plannedMeals") deletePlanMutation.mutate({ id });
}
return (
<div className="mx-auto max-w-5xl pb-8">
<div className="relative -mx-4 -mt-4 overflow-hidden bg-[#45A85A] px-4 pb-8 pt-7 text-white sm:-mx-6 sm:px-6 lg:mx-0 lg:mt-0 lg:rounded-[2rem]">
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-pink-200/70" />
<div className="absolute -left-16 top-24 h-44 w-44 rounded-full bg-teal-100/70" />
<div className="relative flex items-center justify-between">
<button
type="button"
onClick={() => setSelectedDate((current) => addDays(current, -1))}
className="rounded-full bg-white/15 p-3 backdrop-blur"
aria-label="Einen Tag zurück"
title="Einen Tag zurück"
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
<div className="text-center">
<p className="text-sm font-bold uppercase opacity-80">{isToday ? "Heute" : isFuture ? "Planung" : "Rückblick"}</p>
<h1 className="text-4xl font-black tracking-normal">{formatDate(selectedDate).split(",")[0]}</h1>
</div>
<button
type="button"
onClick={() => setSelectedDate((current) => addDays(current, 1))}
className="rounded-full bg-white/15 p-3 backdrop-blur"
aria-label="Einen Tag vor"
title="Einen Tag vor"
>
<ChevronRightIcon className="h-5 w-5" />
</button>
</div>
<div className="relative mt-8 flex items-end justify-between">
<div>
<p className="text-lg font-bold opacity-90">Hellth Hub</p>
<p className="mt-1 max-w-xs text-sm font-medium opacity-75">
{isFuture
? "Zukunftstage sind für Tracking gesperrt, aber für den Essensplan frei."
: "Tracking und Tagesziele für deinen Alltag."}
</p>
</div>
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/20 text-5xl backdrop-blur">HH</div>
</div>
<div className="relative mt-8">
<WeekStrip selectedDate={selectedDate} onSelect={setSelectedDate} />
</div>
</div>
<div className="-mt-3 space-y-4 lg:-mt-6">
{weeklyBudget ? (
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-black text-gray-400">Diese Woche</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">Flexibles Kalorienbudget ohne Tagesstress.</p>
</div>
<a href="/wochenbudget" className="rounded-full bg-gray-950 px-5 py-3 text-sm font-black text-white">Planen</a>
</div>
<div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{weeklyBudget.baseBudget.toLocaleString("de-DE")}</p><p className="text-xs font-bold text-gray-400">Ziel kcal</p></div>
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{weeklyBudget.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">+{weeklyBudget.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">{weeklyBudget.availableCalories.toLocaleString("de-DE")}</p><p className="text-xs font-bold text-blue-700">verfügbar</p></div>
</div>
</section>
) : null}
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-gray-400">
<FireIcon className="h-5 w-5 text-orange-500" />
<p className="text-lg font-black">Kalorien übrig</p>
</div>
<p className="mt-5 text-5xl font-black text-gray-950 dark:text-neutral-100">
{remainingCalories.toLocaleString("de-DE")} <span className="text-3xl text-gray-300">kcal</span>
</p>
</div>
<button
type="button"
onClick={() => setQuickMode(isFuture ? "plan" : "meal")}
className="grid h-20 w-20 place-items-center rounded-full bg-gray-50 text-gray-950 ring-[10px] ring-gray-100"
aria-label="Eintrag hinzufügen"
title="Eintrag hinzufügen"
>
<PlusIcon className="h-8 w-8" />
</button>
</div>
<div className="mt-8">
<MacroBar carbs={goals.carbs} fat={goals.fat} protein={goals.protein} />
<div className="mt-5 grid grid-cols-3 gap-3">
<div>
<p className="text-sm font-bold text-gray-400">Kohlenhydr.</p>
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">{totals.carbs}<span className="text-gray-300">/{goals.carbs} g</span></p>
</div>
<div>
<p className="text-sm font-bold text-gray-400">Fett</p>
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">{totals.fat}<span className="text-gray-300">/{goals.fat} g</span></p>
</div>
<div>
<p className="text-sm font-bold text-gray-400">Proteine</p>
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">{Math.round(totals.protein)}<span className="text-gray-300">/{goals.protein} g</span></p>
</div>
</div>
</div>
<div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400">
<span>Ziel: {goals.calories.toLocaleString("de-DE")} kcal</span>
<EllipsisHorizontalIcon className="h-7 w-7" />
</div>
</section>
{coachRecommendations.length > 0 ? (
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-black text-gray-400">Coach</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">Persönliche Vorschläge aus deinem Fragebogen.</p>
</div>
<a href="/onboarding" className="rounded-full bg-gray-100 px-4 py-2 text-sm font-black text-gray-950">
Anpassen
</a>
</div>
<div className="mt-5 grid gap-3 lg:grid-cols-3">
{coachRecommendations.slice(0, 3).map((item) => (
<article
key={item.id}
className={`rounded-3xl p-4 ${
item.priority === "high"
? "bg-emerald-50 text-emerald-950"
: item.priority === "medium"
? "bg-blue-50 text-blue-950"
: "bg-gray-50 text-gray-950"
}`}
>
<p className="font-black">{item.title}</p>
<p className="mt-2 text-sm font-semibold opacity-75">{item.body}</p>
</article>
))}
</div>
</section>
) : null}
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-center justify-between">
<h2 className="text-xl font-black text-gray-400">Ernährungsplan</h2>
<a href="/planung" className="rounded-full border border-gray-200 px-5 py-2 text-sm font-black text-gray-900 hover:bg-gray-50 dark:text-neutral-100">
Alle ansehen
</a>
</div>
<PlanList
meals={day.plannedMeals.slice(0, 5)}
onRemove={(id) => removeEntry("plannedMeals", id)}
canRemove={day.plannedMeals.length > 0}
/>
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-gray-400">
<BeakerIcon className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-black">Wasser</h2>
</div>
<p className="mt-5 text-5xl font-black text-gray-950 dark:text-neutral-100">
{totals.drinkMl} <span className="text-3xl text-gray-300">ml</span>
</p>
</div>
<button
type="button"
onClick={addDrinkEntry}
disabled={!isEditable || addDrinkMutation.isPending}
className="grid h-20 w-20 place-items-center rounded-full bg-gray-50 text-gray-950 ring-[10px] ring-gray-100 disabled:opacity-40"
aria-label="250 ml Wasser hinzufügen"
title="250 ml Wasser hinzufügen"
>
<PlusIcon className="h-8 w-8" />
</button>
</div>
<WaterGlasses amount={totals.drinkMl} goal={goals.drinkMl} onAdd={addDrinkEntry} locked={!isEditable || addDrinkMutation.isPending} />
<div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400">
<span>Tagesziel: {goals.drinkMl} ml</span>
<EllipsisHorizontalIcon className="h-7 w-7" />
</div>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-gray-400">
<ClockIcon className="h-5 w-5 text-orange-500" />
<h2 className="text-xl font-black">Fasten</h2>
</div>
<p className="mt-5 text-4xl font-black text-gray-200">Bereit zum Starten?</p>
<p className="mt-5 text-sm font-bold text-gray-400">Ziel: {goals.fastingHours} h</p>
</div>
<button type="button" className="grid h-20 w-20 place-items-center rounded-full bg-gray-100 text-sm font-black text-gray-950">
START
</button>
</div>
</div>
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-gray-400">
<ScaleIcon className="h-5 w-5 text-rose-500" />
<h2 className="text-xl font-black">Gewicht</h2>
</div>
<p className="mt-5 text-4xl font-black text-gray-950 dark:text-neutral-100">
{day.weightKg ? `${day.weightKg} kg` : "-"}
</p>
<p className="mt-2 text-sm font-bold text-gray-400">{isEditable ? "Ein Tageswert reicht." : "Für Zukunftstage gesperrt."}</p>
</div>
<div className="grid h-20 w-20 place-items-center rounded-full bg-green-50 text-2xl font-black text-green-600">
{nutritionScore}
</div>
</div>
{isEditable ? (
<div className="mt-6 grid grid-cols-[1fr_96px] gap-3">
<input
type="number"
step="0.1"
value={weightForm}
onChange={(event) => setWeightForm(event.target.value)}
placeholder={day.weightKg ? String(day.weightKg) : "kg"}
className="rounded-full border border-gray-200 px-4 py-3 text-sm"
/>
<button type="button" onClick={saveWeight} disabled={saveWeightMutation.isPending} className="rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40">
OK
</button>
</div>
) : null}
</div>
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-center justify-between">
<h2 className="text-xl font-black text-gray-400">{isFuture ? "Gericht planen" : "Schnellerfassung"}</h2>
{isFuture ? <span className="rounded-full bg-amber-50 px-3 py-1 text-xs font-bold text-amber-700">Tracking gesperrt</span> : null}
</div>
<div className="mt-5 grid grid-cols-2 gap-2 rounded-full bg-gray-100 p-1 sm:grid-cols-4">
{(["meal", "plan", "drink", "weight"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setQuickMode(mode)}
disabled={isFuture && mode !== "plan"}
className={`rounded-full px-4 py-2 text-sm font-black disabled:opacity-40 ${
quickMode === mode ? "bg-white text-gray-950 shadow-sm" : "text-gray-400"
}`}
>
{mode === "meal" ? "Essen" : mode === "plan" ? "Planen" : mode === "drink" ? "Wasser" : "Gewicht"}
</button>
))}
</div>
{quickMode === "meal" ? (
<div className="mt-5 grid gap-3 lg:grid-cols-12">
<select value={foodForm.mealType} onChange={(event) => setFoodForm((current) => ({ ...current, mealType: event.target.value as MealType }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-2">
{mealTypes.map((mealType) => <option key={mealType}>{mealType}</option>)}
</select>
<input value={foodForm.name} onChange={(event) => setFoodForm((current) => ({ ...current, name: event.target.value }))} placeholder="z. B. Skyr mit Haferflocken" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-4" />
<input value={foodForm.amount} onChange={(event) => setFoodForm((current) => ({ ...current, amount: event.target.value }))} placeholder="Menge" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-2" />
<input type="number" value={foodForm.calories} onChange={(event) => setFoodForm((current) => ({ ...current, calories: event.target.value }))} placeholder="kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
<input type="number" value={foodForm.protein} onChange={(event) => setFoodForm((current) => ({ ...current, protein: event.target.value }))} placeholder="Protein" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
<button type="button" onClick={addFoodEntry} disabled={!isEditable || addMealMutation.isPending} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40 lg:col-span-2">
Protokollieren
</button>
</div>
) : null}
{quickMode === "plan" ? (
<div className="mt-5 grid gap-3 lg:grid-cols-12">
<select value={planForm.mealType} onChange={(event) => setPlanForm((current) => ({ ...current, mealType: event.target.value as MealType }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-2">
{mealTypes.map((mealType) => <option key={mealType}>{mealType}</option>)}
</select>
<input value={planForm.name} onChange={(event) => setPlanForm((current) => ({ ...current, name: event.target.value }))} placeholder="Geplantes Gericht" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-4" />
<input value={planForm.note} onChange={(event) => setPlanForm((current) => ({ ...current, note: event.target.value }))} placeholder="Notiz / Reste" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-3" />
<input type="number" min="0.25" step="0.25" value={planForm.portions} onChange={(event) => setPlanForm((current) => ({ ...current, portions: event.target.value }))} placeholder="Portionen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
<input type="number" value={planForm.plannedCalories} onChange={(event) => setPlanForm((current) => ({ ...current, plannedCalories: event.target.value }))} placeholder="kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
<button type="button" onClick={addPlannedMeal} disabled={saveMealPlanMutation.isPending} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40 lg:col-span-1">
Planen
</button>
</div>
) : null}
{quickMode === "drink" ? (
<button type="button" onClick={addDrinkEntry} disabled={!isEditable || addDrinkMutation.isPending} className="mt-5 w-full rounded-2xl bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-40">
250 ml Wasser eintragen
</button>
) : null}
{quickMode === "weight" ? (
<div className="mt-5 grid grid-cols-[1fr_120px] gap-3">
<input type="number" step="0.1" value={weightForm} onChange={(event) => setWeightForm(event.target.value)} placeholder="kg" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<button type="button" onClick={saveWeight} disabled={!isEditable || saveWeightMutation.isPending} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40">
Speichern
</button>
</div>
) : null}
{day.foods.length > 0 ? (
<div className="mt-6 space-y-3">
{day.foods.map((food) => (
<div key={food.id} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
<RecipeAvatar name={food.name} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-black text-gray-950">{food.name}</p>
<p className="text-xs font-bold text-gray-400">{food.mealType} - {food.amount} - {food.calories ?? 0} kcal</p>
</div>
{isEditable ? (
<button type="button" onClick={() => removeEntry("foods", food.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>
) : null}
</div>
))}
</div>
) : null}
</section>
</div>
<button
type="button"
onClick={() => setQuickMode(isFuture ? "plan" : "meal")}
className="fixed bottom-24 right-5 z-40 grid h-20 w-20 place-items-center rounded-full bg-gray-950 text-white shadow-2xl lg:hidden"
aria-label="Neuen Eintrag starten"
title="Neuen Eintrag starten"
>
<PlusIcon className="h-9 w-9" />
</button>
</div>
);
}

View file

@ -0,0 +1,324 @@
"use client";
import {
ArrowLeftIcon,
Cog6ToothIcon,
PlusIcon,
ShoppingCartIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import {
CatalogFood,
MealType,
PortionUnit,
Recipe,
calcRecipe,
todayKey,
weekDates,
} from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
type ApiMealType = "breakfast" | "lunch" | "dinner" | "snack";
const mealTypes: MealType[] = ["Frühstück", "Mittagessen", "Abendessen", "Snack"];
const mealTypeToApi: Record<MealType, ApiMealType> = {
Frühstück: "breakfast",
Mittagessen: "lunch",
Abendessen: "dinner",
Snack: "snack",
};
const mealTypeFromApi: Record<ApiMealType, MealType> = {
breakfast: "Frühstück",
lunch: "Mittagessen",
dinner: "Abendessen",
snack: "Snack",
};
function toNumber(value: unknown) {
return Number(value) || 0;
}
function parseDateKey(key: string) {
return new Date(`${key}T12:00:00`);
}
function apiFoodToCatalog(food: {
id: string;
name: string;
brand: string | null;
category: string;
kcalPer100g: unknown;
proteinPer100g: unknown;
carbsPer100g: unknown;
fatPer100g: unknown;
servingG: unknown;
preferredUnit: PortionUnit | null;
densityGPerMl: unknown | null;
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
barcode: string | null;
availableEverywhere: boolean;
}): CatalogFood {
return {
id: food.id,
name: food.name,
brand: food.brand ?? undefined,
category: food.category,
kcalPer100g: toNumber(food.kcalPer100g),
proteinPer100g: toNumber(food.proteinPer100g),
carbsPer100g: toNumber(food.carbsPer100g),
fatPer100g: toNumber(food.fatPer100g),
servingG: toNumber(food.servingG) || 100,
preferredUnit: food.preferredUnit ?? "g",
densityGPerMl: food.densityGPerMl == null ? undefined : toNumber(food.densityGPerMl),
source: food.source,
barcode: food.barcode ?? undefined,
availableEverywhere: food.availableEverywhere,
};
}
function apiRecipeToRecipe(recipe: {
id: string;
name: string;
portions: number;
minutes: number;
notes: string | null;
ingredients: Array<{
foodId: string;
amountG: unknown;
displayAmount: unknown | null;
displayUnit: PortionUnit | null;
portionId: string | null;
}>;
}): Recipe {
return {
id: recipe.id,
name: recipe.name,
portions: recipe.portions,
minutes: recipe.minutes,
notes: recipe.notes ?? undefined,
ingredients: recipe.ingredients.map((ingredient) => ({
foodId: ingredient.foodId,
amountG: toNumber(ingredient.amountG),
amount: ingredient.displayAmount == null ? undefined : toNumber(ingredient.displayAmount),
unit: ingredient.displayUnit ?? undefined,
portionId: ingredient.portionId ?? undefined,
})),
};
}
function foodSwatch(name: string) {
const colors = [
"from-amber-100 to-orange-200 text-orange-700",
"from-emerald-100 to-lime-200 text-emerald-700",
"from-sky-100 to-blue-200 text-blue-700",
"from-rose-100 to-pink-200 text-rose-700",
];
return colors[name.length % colors.length] ?? colors[0];
}
function RecipeAvatar({ name }: { name: string }) {
const initials = name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
return (
<div className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br ${foodSwatch(name)} text-base font-black shadow-inner`}>
{initials}
</div>
);
}
export default function PlanungPage() {
const utils = trpc.useUtils();
const [selectedDate, setSelectedDate] = useState(todayKey());
const [recipeId, setRecipeId] = useState("");
const [mealType, setMealType] = useState<MealType>("Mittagessen");
const [portions, setPortions] = useState("1");
const [plannedCalories, setPlannedCalories] = useState("");
const [status, setStatus] = useState<string | null>(null);
const days = useMemo(() => weekDates(selectedDate), [selectedDate]);
const startsOn = days[0]!;
const endsOn = days[6]!;
const foodsQuery = trpc.health.listFoods.useQuery({ query: "" });
const recipesQuery = trpc.health.listRecipes.useQuery({ query: "" });
const plansQuery = trpc.health.mealPlansRange.useQuery({ startsOn, endsOn });
const savePlanMutation = trpc.health.upsertMealPlan.useMutation({
onSuccess: async () => {
await utils.health.mealPlansRange.invalidate({ startsOn, endsOn });
setStatus("Gericht geplant.");
},
});
const deletePlanMutation = trpc.health.deleteMealPlan.useMutation({
onSuccess: async () => {
await utils.health.mealPlansRange.invalidate({ startsOn, endsOn });
setStatus("Eintrag entfernt.");
},
});
const generateShoppingListMutation = trpc.health.generateShoppingList.useMutation({
onSuccess: async () => {
await utils.health.shoppingLists.invalidate();
setStatus("Einkaufsliste aus der aktuellen Woche erstellt.");
},
});
const foods = useMemo(() => (foodsQuery.data ?? []).map(apiFoodToCatalog), [foodsQuery.data]);
const recipes = useMemo(() => (recipesQuery.data ?? []).map(apiRecipeToRecipe), [recipesQuery.data]);
useEffect(() => {
if (recipeId || !recipes[0]) return;
setRecipeId(recipes[0].id);
}, [recipeId, recipes]);
const selectedMeals = useMemo(
() => (plansQuery.data ?? []).filter((meal) => meal.date === selectedDate),
[plansQuery.data, selectedDate],
);
const selectedRecipe = useMemo(() => recipes.find((item) => item.id === recipeId) ?? null, [recipeId, recipes]);
const calculatedCalories = useMemo(() => {
if (!selectedRecipe) return 0;
const totals = calcRecipe(selectedRecipe, foods, selectedRecipe.portions);
return Math.round(totals.kcal * (Number(portions) || 1));
}, [foods, portions, selectedRecipe]);
const mealsByType = useMemo(() => {
return mealTypes.reduce<Record<MealType, typeof selectedMeals>>((groups, type) => {
groups[type] = selectedMeals.filter((meal) => mealTypeFromApi[meal.mealType as ApiMealType] === type);
return groups;
}, {} as Record<MealType, typeof selectedMeals>);
}, [selectedMeals]);
const rangeLabel = `${parseDateKey(startsOn).toLocaleDateString("de-DE", { day: "2-digit", month: "short" })} - ${parseDateKey(endsOn).toLocaleDateString("de-DE", { day: "2-digit", month: "short" })}`;
function addPlannedMeal() {
const recipe = selectedRecipe;
if (!recipe) return;
savePlanMutation.mutate({
date: selectedDate,
mealType: mealTypeToApi[mealType],
recipeId: recipe.id,
name: recipe.name,
portions: Number(portions) || 1,
plannedCalories: Number(plannedCalories) || calculatedCalories,
note: recipe.notes ?? null,
});
}
function refreshShoppingList() {
generateShoppingListMutation.mutate({
startsOn,
endsOn,
name: `Einkauf ${rangeLabel}`,
});
}
return (
<div className="mx-auto max-w-5xl space-y-5 pb-24">
<header className="space-y-6 px-1 py-3">
<div className="flex items-center justify-between">
<Link href="/" className="rounded-full p-2 text-gray-950 hover:bg-white" aria-label="Zurück" title="Zurück">
<ArrowLeftIcon className="h-7 w-7" />
</Link>
<Link href="/einstellungen" className="rounded-full p-2 text-gray-950 hover:bg-white" aria-label="Einstellungen" title="Einstellungen">
<Cog6ToothIcon className="h-7 w-7" />
</Link>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-5xl font-black tracking-normal text-gray-950">Ernährungsplan</h1>
<p className="mt-3 text-xl font-black text-gray-400">{rangeLabel}</p>
</div>
<button type="button" onClick={refreshShoppingList} className="inline-flex items-center justify-center gap-2 rounded-full border border-gray-200 px-5 py-3 text-base font-black text-gray-950">
<ShoppingCartIcon className="h-5 w-5" />
Einkaufsliste erstellen
</button>
</div>
<div className="grid grid-cols-7 gap-2">
{days.map((dateKey) => {
const date = parseDateKey(dateKey);
const active = dateKey === selectedDate;
const count = (plansQuery.data ?? []).filter((meal) => meal.date === dateKey).length;
return (
<button
key={dateKey}
type="button"
onClick={() => setSelectedDate(dateKey)}
className={`flex min-h-20 flex-col items-center justify-center rounded-3xl text-sm ${
active ? "bg-white text-gray-950 shadow-sm ring-1 ring-gray-100" : "text-gray-400"
}`}
>
<span className="font-black uppercase">{date.toLocaleDateString("de-DE", { weekday: "narrow" })}</span>
<span className="text-2xl font-black">{date.getDate()}</span>
{count ? <span className="mt-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-700">{count}</span> : null}
</button>
);
})}
</div>
</header>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="grid gap-3 md:grid-cols-[1fr_150px_100px_110px_52px]">
<select value={recipeId} onChange={(event) => setRecipeId(event.target.value)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
{recipes.map((recipe) => (
<option key={recipe.id} value={recipe.id}>
{recipe.name}
</option>
))}
</select>
<select value={mealType} onChange={(event) => setMealType(event.target.value as MealType)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
{mealTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
<input value={portions} onChange={(event) => setPortions(event.target.value)} placeholder="Portionen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<input value={plannedCalories} onChange={(event) => setPlannedCalories(event.target.value)} placeholder={`${calculatedCalories || 0} kcal`} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<button type="button" onClick={addPlannedMeal} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Planen" title="Planen">
<PlusIcon className="h-5 w-5" />
</button>
</div>
{status ? <p className="mt-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
</section>
<div className="space-y-4">
{mealTypes.map((type) => (
<section key={type} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-400">{type}</h2>
<div className="mt-4 divide-y divide-gray-100">
{mealsByType[type].map((meal) => {
const recipe = recipes.find((item) => item.id === meal.recipeId);
const totals = recipe ? calcRecipe(recipe, foods, recipe.portions) : null;
const calories = Number(meal.plannedCalories) || (totals ? Math.round(totals.kcal * Number(meal.portions)) : 0);
return (
<div key={meal.id} className="flex items-center gap-4 py-4">
<RecipeAvatar name={meal.name} />
<div className="min-w-0 flex-1">
<p className="truncate text-lg font-black leading-tight text-gray-950">{meal.name}</p>
<p className="mt-1 text-sm font-bold text-gray-400">
{Number(meal.portions)} Portion(en)
{calories ? ` · ${calories} kcal geplant` : ""}
</p>
</div>
<button type="button" onClick={() => deletePlanMutation.mutate({ id: meal.id })} className="grid h-10 w-10 place-items-center rounded-2xl bg-gray-100 text-gray-400" aria-label="Entfernen" title="Entfernen">
<TrashIcon className="h-5 w-5" />
</button>
</div>
);
})}
{!mealsByType[type].length ? <p className="py-4 text-sm font-bold text-gray-300">Noch nichts geplant.</p> : null}
</div>
</section>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,192 @@
"use client";
import {
ArrowPathIcon,
ClockIcon,
FireIcon,
PlusIcon,
ScaleIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useSession } from "@/lib/auth-client";
import {
RecipeRating,
loadHellthData,
recipeRatingSummary,
saveHellthData,
uid,
} from "@/lib/hellth-data";
const recipeId = "recipe-eggs";
function MacroBar() {
return (
<div className="flex h-4 overflow-hidden rounded-full bg-gray-100">
<div className="w-[22%] bg-amber-400" />
<div className="w-[33%] bg-blue-500" />
<div className="flex-1 bg-green-500" />
</div>
);
}
export default function RecipePage() {
const { data: session } = useSession();
const userKey = session?.user?.id ?? session?.user?.email ?? null;
const displayName = session?.user?.name || session?.user?.email || "Du";
const [ratings, setRatings] = useState<RecipeRating[]>([]);
const [rating, setRating] = useState(5);
const [comment, setComment] = useState("");
const summary = useMemo(() => recipeRatingSummary(recipeId, ratings), [ratings]);
useEffect(() => {
if (!userKey) return;
const data = loadHellthData(userKey);
setRatings(data.recipeRatings);
const own = data.recipeRatings.find((item) => item.recipeId === recipeId && item.userKey === userKey);
if (own) {
setRating(own.rating);
setComment(own.comment ?? "");
}
}, [userKey]);
function saveRating() {
if (!userKey) return;
const data = loadHellthData(userKey);
const nextRating: RecipeRating = {
id: data.recipeRatings.find((item) => item.recipeId === recipeId && item.userKey === userKey)?.id ?? uid("rating"),
recipeId,
userKey,
displayName,
rating,
comment: comment.trim() || undefined,
createdAt: new Date().toISOString(),
};
const nextRatings = [
nextRating,
...data.recipeRatings.filter((item) => !(item.recipeId === recipeId && item.userKey === userKey)),
];
saveHellthData({ ...data, recipeRatings: nextRatings }, userKey);
setRatings(nextRatings);
}
return (
<div className="mx-auto max-w-3xl pb-24">
<section className="rounded-[2.5rem] bg-white px-6 pb-8 pt-5 shadow-sm ring-1 ring-gray-100 sm:px-10">
<div className="mx-auto mb-10 h-2 w-14 rounded-full bg-gray-200" />
<div className="mx-auto flex h-56 w-56 items-center justify-center rounded-full bg-gradient-to-br from-amber-100 to-orange-200 text-6xl font-black text-orange-700 shadow-inner">
EM
</div>
<h1 className="mt-10 text-center text-5xl font-black leading-none tracking-normal text-gray-950">
Eier-Muffins mit Paprika
</h1>
<div className="mt-10 grid grid-cols-3 gap-4 text-center">
<div>
<FireIcon className="mx-auto h-9 w-9 text-green-500" />
<p className="mt-3 text-3xl font-black text-gray-950">523 <span className="text-xl">kcal</span></p>
<p className="text-sm font-black text-gray-400">Pro Portion</p>
</div>
<div>
<ClockIcon className="mx-auto h-9 w-9 text-orange-500" />
<p className="mt-3 text-3xl font-black text-gray-950">20<span className="text-xl"> Min</span></p>
<p className="text-sm font-black text-gray-400">Kochzeit</p>
</div>
<div>
<ScaleIcon className="mx-auto h-9 w-9 text-blue-400" />
<p className="mt-3 text-3xl font-black text-gray-950">88<span className="text-xl">/100</span></p>
<p className="text-sm font-black text-gray-400">Score</p>
</div>
</div>
<div className="mt-8 rounded-[2rem] bg-amber-50 p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-black text-gray-950">User-Bewertung</h2>
<p className="mt-1 text-sm font-bold text-amber-800">
{summary.count ? `${summary.average}/5 aus ${summary.count} Bewertung(en)` : "Noch keine Bewertungen."}
</p>
</div>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
onClick={() => setRating(value)}
className={`text-3xl ${value <= rating ? "text-amber-500" : "text-amber-200"}`}
aria-label={`${value} Sterne`}
title={`${value} Sterne`}
>
*
</button>
))}
</div>
</div>
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Kurzer Kommentar: schmeckt gut, sättigt, Meal-Prep geeignet..."
className="mt-4 min-h-24 w-full rounded-3xl border border-amber-100 bg-white px-4 py-3 text-sm font-semibold"
/>
<button type="button" onClick={saveRating} className="mt-3 rounded-full bg-gray-950 px-5 py-3 text-sm font-black text-white">
Bewertung speichern
</button>
</div>
<div className="my-10 h-px bg-gray-100" />
<h2 className="text-xl font-black text-gray-400">Makros</h2>
<div className="mt-6">
<MacroBar />
<div className="mt-6 grid grid-cols-3 gap-4">
<div>
<p className="text-sm font-black text-gray-400">Kohlenhydr.</p>
<p className="mt-2 text-4xl font-black text-gray-950">20 <span className="text-gray-300">g</span></p>
</div>
<div>
<p className="text-sm font-black text-gray-400">Fett</p>
<p className="mt-2 text-4xl font-black text-gray-950">30 <span className="text-gray-300">g</span></p>
</div>
<div>
<p className="text-sm font-black text-gray-400">Proteine</p>
<p className="mt-2 text-4xl font-black text-gray-950">41 <span className="text-gray-300">g</span></p>
</div>
</div>
</div>
<div className="my-10 h-px bg-gray-100" />
<div className="flex items-center justify-between">
<h2 className="text-xl font-black text-gray-400">Zutaten</h2>
<button type="button" className="text-lg font-black text-gray-400">1 Portionen</button>
</div>
<div className="mt-6 rounded-[2rem] bg-gray-50 p-5">
{[
["Paprika", "150 g"],
["Eier", "3 Stück"],
["Hirtenkäse", "60 g"],
["Zwiebel", "1/2 Stück"],
].map(([name, amount]) => (
<div key={name} className="flex items-center justify-between border-b border-gray-200 py-4 last:border-0">
<p className="text-lg font-black text-gray-950">{name}</p>
<p className="text-lg font-black text-gray-400">{amount}</p>
</div>
))}
</div>
</section>
<div className="fixed inset-x-0 bottom-20 z-40 mx-auto grid max-w-md grid-cols-2 gap-3 px-5 lg:bottom-6">
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-full border border-gray-200 bg-white px-5 py-4 text-base font-black text-gray-950 shadow-sm">
<ArrowPathIcon className="h-5 w-5" />
Tauschen
</button>
<Link href="/" className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-4 text-base font-black text-white shadow-sm">
<PlusIcon className="h-5 w-5" />
Protokollieren
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,475 @@
"use client";
import { PlusIcon, StarIcon } from "@heroicons/react/24/outline";
import { useEffect, useMemo, useState } from "react";
import { FormField, inputClassName } from "@/app/_components/form-field";
import {
CatalogFood,
PortionUnit,
Recipe,
amountToGrams,
calcRecipe,
classifyRecipe,
describeIngredientAmount,
unitLabel,
} from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
const recipeUnits: PortionUnit[] = ["g", "ml", "tsp", "tbsp", "piece", "slice", "cup", "pack", "bottle"];
function toNumber(value: unknown) {
return Number(value) || 0;
}
function toCatalogFood(food: {
id: string;
name: string;
brand: string | null;
category: string;
kcalPer100g: unknown;
proteinPer100g: unknown;
carbsPer100g: unknown;
fatPer100g: unknown;
servingG: unknown;
preferredUnit: PortionUnit | null;
densityGPerMl: unknown | null;
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
barcode: string | null;
availableEverywhere: boolean;
portions?: Array<{
id: string;
label: string;
unit: PortionUnit;
amount: unknown;
gramsEquivalent: unknown;
}>;
}): CatalogFood {
return {
id: food.id,
name: food.name,
brand: food.brand ?? undefined,
category: food.category,
kcalPer100g: toNumber(food.kcalPer100g),
proteinPer100g: toNumber(food.proteinPer100g),
carbsPer100g: toNumber(food.carbsPer100g),
fatPer100g: toNumber(food.fatPer100g),
servingG: toNumber(food.servingG) || 100,
preferredUnit: food.preferredUnit ?? "g",
densityGPerMl: food.densityGPerMl == null ? undefined : toNumber(food.densityGPerMl),
portions: food.portions?.map((portion) => ({
id: portion.id,
label: portion.label,
unit: portion.unit,
amount: toNumber(portion.amount),
gramsEquivalent: toNumber(portion.gramsEquivalent),
})),
source: food.source,
barcode: food.barcode ?? undefined,
availableEverywhere: food.availableEverywhere,
};
}
function recipeFromApi(recipe: {
id: string;
name: string;
portions: number;
minutes: number;
notes: string | null;
ingredients: Array<{
foodId: string;
amountG: unknown;
displayAmount: unknown | null;
displayUnit: PortionUnit | null;
portionId: string | null;
}>;
}): Recipe {
return {
id: recipe.id,
name: recipe.name,
portions: recipe.portions,
minutes: recipe.minutes,
notes: recipe.notes ?? undefined,
ingredients: recipe.ingredients.map((ingredient) => ({
foodId: ingredient.foodId,
amountG: toNumber(ingredient.amountG),
amount: ingredient.displayAmount == null ? undefined : toNumber(ingredient.displayAmount),
unit: ingredient.displayUnit ?? undefined,
portionId: ingredient.portionId ?? undefined,
})),
};
}
function MacroBar({ distribution }: { distribution: { carbs: number; fat: number; protein: number } }) {
return (
<div className="flex h-3 overflow-hidden rounded-full bg-gray-100">
<div className="bg-amber-400" style={{ width: `${distribution.carbs}%` }} />
<div className="bg-blue-500" style={{ width: `${distribution.fat}%` }} />
<div className="bg-emerald-500" style={{ width: `${distribution.protein}%` }} />
</div>
);
}
function RecipeNutritionInsight({ recipe, foods }: { recipe: Recipe; foods: CatalogFood[] }) {
const totals = calcRecipe(recipe, foods);
const classification = classifyRecipe(recipe, foods);
return (
<div className="rounded-3xl bg-gray-50 p-4">
<div className="flex flex-wrap gap-2">
{classification.tags.map((tag) => (
<span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-black text-gray-600">
{tag}
</span>
))}
</div>
<div className="mt-4">
<MacroBar distribution={classification.distribution} />
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
<div><p className="text-lg font-black text-gray-950">{totals.kcal}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH {classification.distribution.carbs}%</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett {classification.distribution.fat}%</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Eiweiß {classification.distribution.protein}%</p></div>
</div>
</div>
<div className="mt-4 grid gap-2">
{classification.criteria.map((criterion) => (
<div key={criterion.label} className="flex items-center justify-between gap-3 text-sm font-bold">
<span className={criterion.ok ? "text-emerald-700" : "text-amber-700"}>{criterion.ok ? "OK" : "!"} {criterion.label}</span>
<span className="text-gray-400">{criterion.detail}</span>
</div>
))}
</div>
</div>
);
}
export default function RezeptePage() {
const utils = trpc.useUtils();
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [minutes, setMinutes] = useState("20");
const [portions, setPortions] = useState("2");
const [mealPrepDays, setMealPrepDays] = useState("1");
const [foodId, setFoodId] = useState("");
const [amount, setAmount] = useState("100");
const [unit, setUnit] = useState<PortionUnit>("g");
const [portionId, setPortionId] = useState("");
const [draftIngredients, setDraftIngredients] = useState<Recipe["ingredients"]>([]);
const [status, setStatus] = useState<string | null>(null);
const [suggestMealPrepDays, setSuggestMealPrepDays] = useState("0");
const [suggestCooksPerDay, setSuggestCooksPerDay] = useState("1");
const foodsQuery = trpc.health.listFoods.useQuery({ query: "" });
const recipesQuery = trpc.health.listRecipes.useQuery({ query: "" });
const recommendationsQuery = trpc.health.recipeRecommendations.useQuery({
mealPrepDays: Number(suggestMealPrepDays) || 0,
cooksPerDay: Number(suggestCooksPerDay) || 1,
limit: 8,
});
const submissionsQuery = trpc.health.myRecipeSubmissions.useQuery();
const saveRecipeMutation = trpc.health.saveRecipe.useMutation({
onSuccess: async () => {
await utils.health.listRecipes.invalidate();
await utils.health.recipeRecommendations.invalidate();
setName("");
setNotes("");
setDraftIngredients([]);
setStatus("Rezept gespeichert.");
},
});
const submitRecipeMutation = trpc.health.submitRecipeSuggestion.useMutation({
onSuccess: async (result) => {
await utils.health.myRecipeSubmissions.invalidate();
await utils.health.recipeRecommendations.invalidate();
await utils.health.clubLeaderboard.invalidate();
await utils.health.activityWeek.invalidate();
setName("");
setNotes("");
setDraftIngredients([]);
setStatus(`Rezeptvorschlag wurde zur Prüfung eingereicht. +${result.points} Hellfireclub-Punkte.`);
},
});
const foods = useMemo(() => (foodsQuery.data ?? []).map(toCatalogFood), [foodsQuery.data]);
const recipes = useMemo(() => (recipesQuery.data ?? []).map(recipeFromApi), [recipesQuery.data]);
const foodById = useMemo(() => new Map(foods.map((food) => [food.id, food])), [foods]);
const selectedFood = foodId ? foodById.get(foodId) : foods[0];
const draftRecipe = useMemo<Recipe>(() => ({
id: "draft",
name: name.trim() || "Entwurf",
portions: Number(portions) || 1,
minutes: Number(minutes) || 0,
notes: notes.trim() || undefined,
ingredients: draftIngredients,
}), [draftIngredients, minutes, name, notes, portions]);
useEffect(() => {
if (foodId || !foods[0]) return;
setFoodId(foods[0].id);
setUnit(foods[0].preferredUnit ?? "g");
}, [foodId, foods]);
function recipePayload() {
return {
name: name.trim(),
portions: Number(portions) || 1,
minutes: Number(minutes) || 0,
notes: notes.trim() || null,
mealPrepDays: Number(mealPrepDays) || 0,
ingredients: draftIngredients.map((ingredient, index) => ({
foodId: ingredient.foodId,
amountG: ingredient.amountG,
displayAmount: ingredient.amount ?? null,
displayUnit: ingredient.unit ?? null,
portionId: ingredient.portionId ?? null,
sortOrder: index,
})),
};
}
function addIngredient() {
if (!selectedFood) return;
const amountNumber = Number(amount.replace(",", ".")) || 1;
const amountG = amountToGrams(selectedFood, amountNumber, unit, portionId || undefined);
setDraftIngredients((current) => [
...current,
{
foodId: selectedFood.id,
amount: amountNumber,
unit,
portionId: portionId || undefined,
amountG: Math.round(amountG * 10) / 10,
},
]);
}
function saveRecipe() {
if (!name.trim() || draftIngredients.length === 0) return;
saveRecipeMutation.mutate({ ...recipePayload(), isPublic: false });
}
function submitRecipeSuggestion() {
if (!name.trim() || draftIngredients.length === 0) return;
submitRecipeMutation.mutate(recipePayload());
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Health-API</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Gerichte & Rezepte</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Zutaten aus dem Lebensmittelkatalog, Alltagseinheiten, Portionen, Nährwerte und Meal-Prep-Notizen.
</p>
</header>
<section className="grid gap-4 xl:grid-cols-[1fr_0.85fr]">
<div className="space-y-4">
{recipes.map((recipe) => {
const totals = calcRecipe(recipe, foods);
return (
<article key={recipe.id} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-2xl font-black text-gray-950">{recipe.name}</p>
<p className="mt-1 text-sm font-bold text-gray-400">
{recipe.minutes} min · {recipe.portions} Portionen
{recipe.notes ? ` · ${recipe.notes}` : ""}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<div className="rounded-full bg-green-50 px-4 py-2 text-sm font-black text-green-700">{totals.kcal} kcal</div>
<div className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-4 py-2 text-sm font-black text-amber-700">
<StarIcon className="h-4 w-4" />
Bewertung folgt
</div>
</div>
</div>
<div className="mt-5 grid grid-cols-3 gap-3 text-center">
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH</p></div>
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett</p></div>
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
</div>
<div className="mt-5">
<RecipeNutritionInsight recipe={recipe} foods={foods} />
</div>
<div className="mt-5 space-y-2">
{recipe.ingredients.map((ingredient, index) => (
<div key={`${ingredient.foodId}-${index}`} className="flex justify-between gap-3 text-sm font-bold text-gray-500">
<span className="min-w-0 truncate">{foodById.get(ingredient.foodId)?.name ?? "Unbekannt"}</span>
<span>{describeIngredientAmount(ingredient)} (~{Math.round(ingredient.amountG)} g)</span>
</div>
))}
</div>
</article>
);
})}
{!recipes.length && !recipesQuery.isLoading ? (
<p className="rounded-[2rem] bg-white p-6 text-sm font-bold text-gray-400 shadow-sm ring-1 ring-gray-100">
Noch keine Rezepte gespeichert.
</p>
) : null}
</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">Vorschläge für dich</h2>
<div className="mt-4 grid grid-cols-2 gap-2">
<label className="space-y-1">
<span className="text-xs font-black uppercase text-gray-400">Mealprep Tage</span>
<select value={suggestMealPrepDays} onChange={(event) => setSuggestMealPrepDays(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold">
<option value="0">kein Fokus</option>
<option value="2">2 Tage</option>
<option value="3">3 Tage</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-black uppercase text-gray-400">Kochen/Tag</span>
<select value={suggestCooksPerDay} onChange={(event) => setSuggestCooksPerDay(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold">
<option value="1">1x</option>
<option value="2">2x</option>
</select>
</label>
</div>
<div className="mt-4 space-y-3">
{(recommendationsQuery.data ?? []).map((recommendation) => (
<article key={recommendation.id} className="rounded-3xl bg-gray-50 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-black text-gray-950">{recommendation.name}</p>
<p className="mt-1 text-xs font-bold text-gray-400">
{recommendation.minutes} min · Mealprep {recommendation.mealPrepDays || 1} Tag(e)
</p>
</div>
<span className="rounded-full bg-white px-3 py-1 text-xs font-black text-[#0F766E]">{recommendation.score}</span>
</div>
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
<div><p className="text-sm font-black">{recommendation.nutrition.kcal}</p><p className="text-[10px] font-bold text-gray-400">kcal</p></div>
<div><p className="text-sm font-black">{recommendation.nutrition.carbs} g</p><p className="text-[10px] font-bold text-gray-400">KH</p></div>
<div><p className="text-sm font-black">{recommendation.nutrition.fat} g</p><p className="text-[10px] font-bold text-gray-400">Fett</p></div>
<div><p className="text-sm font-black">{recommendation.nutrition.protein} g</p><p className="text-[10px] font-bold text-gray-400">Eiweiß</p></div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{[...recommendation.tags, ...recommendation.reasons].slice(0, 5).map((tag) => (
<span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-500">{tag}</span>
))}
</div>
</article>
))}
{!recommendationsQuery.isFetching && !(recommendationsQuery.data ?? []).length ? (
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">
Noch keine passenden Vorschläge. Prüfe deine Profil-Präferenzen oder lege mehr Rezepte an.
</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">Neues Gericht</h2>
<div className="mt-4 grid gap-3">
<FormField label="Name des Gerichts">
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="z. B. Protein Bowl" className={inputClassName} />
</FormField>
<FormField label="Notiz">
<textarea value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Reste, Meal-Prep-Hinweis oder Zubereitungsidee" className={`${inputClassName} min-h-20`} />
</FormField>
<div className="grid grid-cols-3 gap-2">
<FormField label="Minuten">
<input value={minutes} onChange={(event) => setMinutes(event.target.value)} placeholder="20" className={inputClassName} />
</FormField>
<FormField label="Portionen">
<input value={portions} onChange={(event) => setPortions(event.target.value)} placeholder="2" className={inputClassName} />
</FormField>
<FormField label="Mealprep">
<input value={mealPrepDays} onChange={(event) => setMealPrepDays(event.target.value)} placeholder="Tage" className={inputClassName} />
</FormField>
</div>
<div className="grid gap-2">
<FormField label="Lebensmittel">
<select
value={foodId}
onChange={(event) => {
const nextFood = foodById.get(event.target.value);
setFoodId(event.target.value);
setUnit(nextFood?.preferredUnit ?? "g");
setPortionId("");
}}
className={inputClassName}
>
{foods.map((food) => <option key={food.id} value={food.id}>{food.name}</option>)}
</select>
</FormField>
<div className="grid grid-cols-[1fr_112px_48px] gap-2">
<FormField label="Menge">
<input value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="100" className={inputClassName} />
</FormField>
<FormField label="Einheit">
<select value={unit} onChange={(event) => setUnit(event.target.value as PortionUnit)} className={inputClassName}>
{recipeUnits.map((recipeUnit) => <option key={recipeUnit} value={recipeUnit}>{unitLabel(recipeUnit)}</option>)}
</select>
</FormField>
<button type="button" onClick={addIngredient} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Zutat hinzufügen" title="Zutat hinzufügen">
<PlusIcon className="h-5 w-5" />
</button>
</div>
{selectedFood?.portions?.length ? (
<FormField label="Portionsgröße">
<select value={portionId} onChange={(event) => setPortionId(event.target.value)} className={inputClassName}>
<option value="">Freie Menge verwenden</option>
{selectedFood.portions.map((portion) => (
<option key={portion.id} value={portion.id}>{portion.label} ({portion.amount} {unitLabel(portion.unit)} = {portion.gramsEquivalent} g)</option>
))}
</select>
</FormField>
) : null}
</div>
<div className="rounded-3xl bg-gray-50 p-4">
{draftIngredients.length ? draftIngredients.map((ingredient, index) => (
<div key={`${ingredient.foodId}-${index}`} className="flex justify-between gap-3 py-1 text-sm font-bold text-gray-500">
<span className="min-w-0 truncate">{foodById.get(ingredient.foodId)?.name}</span>
<span>{describeIngredientAmount(ingredient)} (~{Math.round(ingredient.amountG)} g)</span>
</div>
)) : <p className="text-sm font-bold text-gray-400">Noch keine Zutaten.</p>}
</div>
{draftIngredients.length ? <RecipeNutritionInsight recipe={draftRecipe} foods={foods} /> : null}
<div className="grid gap-2 sm:grid-cols-2">
<button type="button" onClick={saveRecipe} disabled={saveRecipeMutation.isPending || !foods.length} className="rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50">
Privat speichern
</button>
<button type="button" onClick={submitRecipeSuggestion} disabled={submitRecipeMutation.isPending || !foods.length} className="rounded-full bg-[#0F766E] px-4 py-4 text-sm font-black text-white disabled:opacity-50">
Zur Prüfung einreichen
</button>
</div>
{status ? <p className="rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
{saveRecipeMutation.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{saveRecipeMutation.error.message}</p> : null}
{submitRecipeMutation.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{submitRecipeMutation.error.message}</p> : null}
{submissionsQuery.data?.length ? (
<div className="rounded-3xl bg-gray-50 p-4">
<h3 className="text-sm font-black text-gray-950">Meine Vorschläge</h3>
<div className="mt-3 space-y-2">
{submissionsQuery.data.slice(0, 5).map((submission) => (
<div key={submission.id} className="flex items-center justify-between gap-3 text-sm font-bold text-gray-500">
<span className="min-w-0 truncate">{submission.name}</span>
<span className={`rounded-full px-3 py-1 text-xs ${
submission.status === "accepted"
? "bg-emerald-100 text-emerald-700"
: submission.status === "rejected"
? "bg-red-100 text-red-700"
: "bg-amber-100 text-amber-700"
}`}>
{submission.status === "accepted" ? "akzeptiert" : submission.status === "rejected" ? "abgelehnt" : "in Prüfung"}
</span>
</div>
))}
</div>
</div>
) : null}
</div>
</section>
</aside>
</section>
</div>
);
}

View file

@ -0,0 +1,226 @@
"use client";
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { CatalogFood, PortionUnit, Recipe, calcRecipe, classifyRecipe } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
type SubmissionStatus = "pending" | "accepted" | "rejected";
type SubmissionForReview = {
id: string;
name: string;
portions: number;
minutes: number;
notes: string | null;
ingredients: Array<{
id: string;
foodId: string;
amountG: unknown;
displayAmount: unknown | null;
displayUnit: PortionUnit | null;
portionId: string | null;
food: Parameters<typeof foodFromSubmission>[0] | null;
}>;
};
function toNumber(value: unknown) {
return Number(value) || 0;
}
function foodFromSubmission(food: {
id: string;
name: string;
brand: string | null;
category: string;
kcalPer100g: unknown;
proteinPer100g: unknown;
carbsPer100g: unknown;
fatPer100g: unknown;
servingG: unknown;
preferredUnit: PortionUnit | null;
densityGPerMl: unknown | null;
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
barcode: string | null;
availableEverywhere: boolean;
}): CatalogFood {
return {
id: food.id,
name: food.name,
brand: food.brand ?? undefined,
category: food.category,
kcalPer100g: toNumber(food.kcalPer100g),
proteinPer100g: toNumber(food.proteinPer100g),
carbsPer100g: toNumber(food.carbsPer100g),
fatPer100g: toNumber(food.fatPer100g),
servingG: toNumber(food.servingG) || 100,
preferredUnit: food.preferredUnit ?? "g",
densityGPerMl: food.densityGPerMl == null ? undefined : toNumber(food.densityGPerMl),
source: food.source,
barcode: food.barcode ?? undefined,
availableEverywhere: food.availableEverywhere,
};
}
function SubmissionNutrition({ submission }: { submission: SubmissionForReview }) {
const foods = submission.ingredients.map((ingredient) => ingredient.food ? foodFromSubmission(ingredient.food) : null).filter(Boolean) as CatalogFood[];
const recipe: Recipe = {
id: submission.id,
name: submission.name,
portions: submission.portions,
minutes: submission.minutes,
notes: submission.notes ?? undefined,
ingredients: submission.ingredients.map((ingredient) => ({
foodId: ingredient.foodId,
amountG: toNumber(ingredient.amountG),
amount: ingredient.displayAmount == null ? undefined : toNumber(ingredient.displayAmount),
unit: ingredient.displayUnit ?? undefined,
portionId: ingredient.portionId ?? undefined,
})),
};
const totals = calcRecipe(recipe, foods);
const classification = classifyRecipe(recipe, foods);
return (
<div className="mt-5 rounded-3xl bg-gray-50 p-4">
<div className="flex flex-wrap gap-2">
{classification.tags.map((tag) => <span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-black text-gray-600">{tag}</span>)}
</div>
<div className="mt-4 flex h-3 overflow-hidden rounded-full bg-white">
<div className="bg-amber-400" style={{ width: `${classification.distribution.carbs}%` }} />
<div className="bg-blue-500" style={{ width: `${classification.distribution.fat}%` }} />
<div className="bg-emerald-500" style={{ width: `${classification.distribution.protein}%` }} />
</div>
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
<div><p className="text-lg font-black text-gray-950">{totals.kcal}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH {classification.distribution.carbs}%</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett {classification.distribution.fat}%</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Eiweiß {classification.distribution.protein}%</p></div>
</div>
<div className="mt-4 grid gap-2">
{classification.criteria.map((criterion) => (
<div key={criterion.label} className="flex items-center justify-between gap-3 text-sm font-bold">
<span className={criterion.ok ? "text-emerald-700" : "text-amber-700"}>{criterion.ok ? "OK" : "!"} {criterion.label}</span>
<span className="text-gray-400">{criterion.detail}</span>
</div>
))}
</div>
</div>
);
}
export default function RezeptPruefungPage() {
const utils = trpc.useUtils();
const [status, setStatus] = useState<SubmissionStatus>("pending");
const [reviewNotes, setReviewNotes] = useState<Record<string, string>>({});
const submissionsQuery = trpc.health.recipeSubmissionsForReview.useQuery({ status });
const reviewMutation = trpc.health.reviewRecipeSubmission.useMutation({
onSuccess: async () => {
await utils.health.recipeSubmissionsForReview.invalidate({ status });
await utils.health.listRecipes.invalidate();
},
});
const submissions = useMemo(() => submissionsQuery.data ?? [], [submissionsQuery.data]);
function review(id: string, action: "accept" | "reject") {
reviewMutation.mutate({
id,
action,
reviewNote: reviewNotes[id]?.trim() || null,
});
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Moderation</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Rezeptvorschläge prüfen</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Akzeptierte Vorschläge werden als öffentliche Rezepte in den Rezeptkatalog übernommen.
</p>
</header>
<section className="rounded-[2rem] bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="grid grid-cols-3 gap-2 rounded-full bg-gray-100 p-1">
{(["pending", "accepted", "rejected"] as const).map((item) => (
<button
key={item}
type="button"
onClick={() => setStatus(item)}
className={`rounded-full px-4 py-2 text-sm font-black ${
status === item ? "bg-white text-gray-950 shadow-sm" : "text-gray-400"
}`}
>
{item === "pending" ? "Offen" : item === "accepted" ? "Akzeptiert" : "Abgelehnt"}
</button>
))}
</div>
</section>
<div className="grid gap-4">
{submissions.map((submission) => (
<article key={submission.id} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-2xl font-black text-gray-950">{submission.name}</p>
<p className="mt-1 text-sm font-bold text-gray-400">
{submission.minutes} min · {submission.portions} Portionen
{submission.mealPrepDays ? ` · ${submission.mealPrepDays} Meal-Prep-Tag(e)` : ""}
</p>
{submission.notes ? <p className="mt-3 max-w-2xl text-sm font-semibold text-gray-500">{submission.notes}</p> : null}
</div>
<span className={`rounded-full px-4 py-2 text-sm font-black ${
submission.status === "accepted"
? "bg-emerald-50 text-emerald-700"
: submission.status === "rejected"
? "bg-red-50 text-red-700"
: "bg-amber-50 text-amber-700"
}`}>
{submission.status === "accepted" ? "akzeptiert" : submission.status === "rejected" ? "abgelehnt" : "offen"}
</span>
</div>
<div className="mt-5 divide-y divide-gray-100 rounded-3xl bg-gray-50 px-4">
{submission.ingredients.map((ingredient) => (
<div key={ingredient.id} className="flex justify-between gap-3 py-3 text-sm font-bold text-gray-500">
<span className="min-w-0 truncate">{ingredient.food?.name ?? "Unbekanntes Lebensmittel"}</span>
<span>{toNumber(ingredient.displayAmount) || toNumber(ingredient.amountG)} {ingredient.displayUnit ?? "g"} · {Math.round(toNumber(ingredient.amountG))} g</span>
</div>
))}
</div>
<SubmissionNutrition submission={submission} />
{submission.status === "pending" ? (
<div className="mt-5 grid gap-3 lg:grid-cols-[1fr_150px_150px]">
<input
value={reviewNotes[submission.id] ?? ""}
onChange={(event) => setReviewNotes((current) => ({ ...current, [submission.id]: event.target.value }))}
placeholder="Prüfnotiz optional"
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
/>
<button type="button" onClick={() => review(submission.id, "reject")} disabled={reviewMutation.isPending} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-red-50 px-4 py-3 text-sm font-black text-red-700 disabled:opacity-50">
<XMarkIcon className="h-5 w-5" />
Ablehnen
</button>
<button type="button" onClick={() => review(submission.id, "accept")} disabled={reviewMutation.isPending} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#0F766E] px-4 py-3 text-sm font-black text-white disabled:opacity-50">
<CheckIcon className="h-5 w-5" />
Akzeptieren
</button>
</div>
) : submission.reviewNote ? (
<p className="mt-4 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-bold text-gray-500">{submission.reviewNote}</p>
) : null}
</article>
))}
{!submissions.length && !submissionsQuery.isLoading ? (
<p className="rounded-[2rem] bg-white p-6 text-sm font-bold text-gray-400 shadow-sm ring-1 ring-gray-100">
Keine Rezeptvorschläge in dieser Ansicht.
</p>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,262 @@
"use client";
import { authClient, useSession } from "@/lib/auth-client";
import { trpc } from "@/lib/trpc";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
export default function TwoFactorSetupPage() {
const router = useRouter();
const { data: session } = useSession();
const securityQuery = trpc.security.status.useQuery();
const [password, setPassword] = useState("");
const [otpCode, setOtpCode] = useState("");
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [totpUri, setTotpUri] = useState<string | null>(null);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const qrCodeUrl = useMemo(() => {
if (!totpUri) return null;
const sanitized = totpUri.trim();
return `https://api.qrserver.com/v1/create-qr-code/?size=320x320&margin=16&data=${encodeURIComponent(
sanitized,
)}`;
}, [totpUri]);
const manualSecret = useMemo(() => {
if (!totpUri) return null;
const match = totpUri.match(/[?&]secret=([^&]+)/i);
if (!match?.[1]) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
}, [totpUri]);
const requiresSetup = securityQuery.data?.requiresTwoFactorSetup ?? false;
async function handleEnableTwoFactor() {
setErrorMessage(null);
setStatusMessage(null);
setLoading(true);
try {
const result = (await authClient.twoFactor.enable({
password,
issuer: "Hellth Hub",
})) as unknown as {
error?: { message?: string };
data?: { totpURI?: string; backupCodes?: string[] };
totpURI?: string;
backupCodes?: string[];
};
if (result?.error) {
setErrorMessage(result.error.message ?? "2FA konnte nicht aktiviert werden.");
return;
}
const data = result.data ?? result;
setTotpUri(data.totpURI ?? null);
setBackupCodes(data.backupCodes ?? []);
setStatusMessage(
"OTP vorbereitet. Bitte QR-Code scannen und danach den 6-stelligen Code bestätigen.",
);
setPassword("");
await securityQuery.refetch();
} catch {
setErrorMessage("2FA konnte derzeit nicht aktiviert werden.");
} finally {
setLoading(false);
}
}
async function handleVerifyTotp() {
setErrorMessage(null);
setStatusMessage(null);
setLoading(true);
try {
if (!totpUri) {
setErrorMessage(
"OTP ist noch nicht vorbereitet. Bitte zuerst in Schritt 1 mit deinem Passwort aktivieren.",
);
return;
}
const result = (await authClient.twoFactor.verifyTotp({
code: otpCode.trim(),
})) as unknown as {
error?: { message?: string };
};
if (result?.error) {
const message = result.error.message ?? "OTP-Code ist ungültig.";
if (message.toLowerCase().includes("totp not enabled")) {
setErrorMessage(
"TOTP ist noch nicht aktiviert. Bitte Schritt 1 (OTP aktivieren) ausführen.",
);
return;
}
setErrorMessage(message);
return;
}
setStatusMessage("2FA erfolgreich aktiviert.");
setOtpCode("");
const refreshed = await securityQuery.refetch();
if (!refreshed.data?.requiresTwoFactorSetup) {
router.push("/");
}
} catch {
setErrorMessage("OTP-Code konnte nicht verifiziert werden.");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h1 className="text-xl font-bold text-[#333333]">Sicherheits-Setup</h1>
<p className="text-sm text-gray-600 mt-2">
Für Admin-Accounts ist 2FA verpflichtend. Bitte richte jetzt OTP (Authenticator-App)
ein.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<h2 className="text-base font-semibold text-[#333333]">1. OTP aktivieren</h2>
<p className="text-sm text-gray-600">
Gib dein aktuelles Passwort ein, um die OTP-Einrichtung zu starten.
</p>
<input
type="password"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
placeholder="Aktuelles Passwort"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<button
onClick={handleEnableTwoFactor}
disabled={loading || !password}
className="px-4 py-2 rounded-lg bg-[#0F766E] text-white text-sm disabled:opacity-50"
>
{loading ? "Aktiviere..." : "OTP aktivieren"}
</button>
</section>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<h2 className="text-base font-semibold text-[#333333]">2. QR-Code scannen</h2>
{!qrCodeUrl && (
<p className="text-sm text-gray-500">
Nach der Aktivierung erscheint hier der QR-Code für deine Authenticator-App.
</p>
)}
{qrCodeUrl && (
<div className="space-y-3">
<img
src={qrCodeUrl}
alt="OTP QR Code"
className="w-72 h-72 rounded-lg bg-white p-2"
/>
<p className="text-xs text-gray-500 break-all">{totpUri}</p>
{manualSecret && (
<div className="text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
Falls Scannen nicht klappt, manuell eingeben:{" "}
<code className="font-mono">{manualSecret}</code>
</div>
)}
</div>
)}
</section>
</div>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<h2 className="text-base font-semibold text-[#333333]">3. Backup-Codes sichern</h2>
{backupCodes.length === 0 ? (
<p className="text-sm text-gray-500">
Nach Aktivierung werden hier Backup-Codes angezeigt.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{backupCodes.map((code) => (
<code
key={code}
className="px-3 py-2 rounded bg-gray-100 text-sm font-mono text-gray-800"
>
{code}
</code>
))}
</div>
)}
</section>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<h2 className="text-base font-semibold text-[#333333]">4. OTP-Code bestätigen</h2>
<p className="text-sm text-gray-600">
Bitte den aktuellen 6-stelligen Code aus deiner Authenticator-App eingeben.
</p>
<input
className="w-full max-w-xs border border-gray-200 rounded-lg px-3 py-2 text-sm tracking-[0.3em]"
placeholder="123456"
maxLength={6}
value={otpCode}
onChange={(event) =>
setOtpCode(event.target.value.replace(/\D/g, "").slice(0, 6))
}
/>
<button
onClick={handleVerifyTotp}
disabled={loading || otpCode.length !== 6 || !totpUri}
className="px-4 py-2 rounded-lg bg-gray-900 text-white text-sm disabled:opacity-50"
>
OTP bestätigen
</button>
</section>
<section className="bg-white border border-amber-200 rounded-xl p-5 space-y-2">
<h2 className="text-base font-semibold text-amber-800">Passkey Hinweis</h2>
<p className="text-sm text-amber-700">
Passkey ist jetzt verfügbar. Du kannst in den Einstellungen unter Sicherheit einen
Passkey hinterlegen. Für Admin-Accounts bleibt OTP weiterhin verpflichtend.
</p>
</section>
{statusMessage && (
<div className="text-sm rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-green-800">
{statusMessage}
</div>
)}
{errorMessage && (
<div className="text-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{errorMessage}
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={() => securityQuery.refetch()}
className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 text-sm"
>
Status aktualisieren
</button>
<button
onClick={() => router.push("/")}
disabled={requiresSetup}
className="px-4 py-2 rounded-lg bg-gray-900 text-white text-sm disabled:opacity-50"
>
Zum Dashboard
</button>
</div>
{!session && (
<div className="text-sm rounded-lg border border-gray-200 bg-white px-4 py-3 text-gray-600">
Keine aktive Session gefunden. Bitte erneut einloggen.
</div>
)}
</div>
);
}

View file

@ -0,0 +1,213 @@
"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>
);
}

View file

@ -0,0 +1,109 @@
"use client";
import {
ArrowPathIcon,
BoltIcon,
CheckCircleIcon,
DevicePhoneMobileIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { trpc } from "@/lib/trpc";
const plannedImports = [
"Schritte und aktive Minuten",
"Trainingseinheiten von der Pixel Watch",
"Aktivitätskalorien als optionaler Sportbonus",
"Dublettenprüfung über externe Aktivitäts-IDs",
"Punktevergabe für importierte Aktivitäten",
];
export default function WearablesPage() {
const connectionsQuery = trpc.health.wearableConnections.useQuery();
const connections = connectionsQuery.data ?? [];
const fitbit = connections.find((connection) => connection.provider === "fitbit");
return (
<div className="mx-auto max-w-5xl 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 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-black uppercase text-emerald-300">Pixel Watch & Fitbit</p>
<h1 className="mt-2 text-4xl font-black tracking-normal">Wearables</h1>
<p className="mt-3 max-w-2xl text-sm font-semibold text-white/60">
Importiere Aktivitäten aus deiner Uhr und verwandle sie in Sportbonus, Aktivitätspunkte und bessere Wochenprognosen.
</p>
</div>
<div className="grid h-28 w-28 place-items-center rounded-full bg-white text-gray-950 ring-[12px] ring-white/10">
<DevicePhoneMobileIcon className="h-14 w-14" />
</div>
</div>
</header>
<section className="grid gap-4 lg:grid-cols-[1fr_0.8fr]">
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-950">Fitbit</h2>
<p className="mt-2 text-sm font-semibold text-gray-400">
Empfohlener erster Weg für die Pixel Watch. Google Fit wird nicht mehr als neue Basis aufgebaut; Health Connect bleibt als spätere Android-Brücke vorgesehen.
</p>
</div>
<span className={`rounded-full px-3 py-1 text-xs font-black ${fitbit ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"}`}>
{fitbit ? "verbunden" : "vorbereitet"}
</span>
</div>
<div className="mt-5 rounded-3xl bg-gray-50 p-4">
{fitbit ? (
<div className="flex items-center gap-3">
<CheckCircleIcon className="h-7 w-7 text-emerald-600" />
<div>
<p className="text-sm font-black text-gray-950">Fitbit-Verbindung aktiv</p>
<p className="text-xs font-bold text-gray-400">
Letzter Sync: {fitbit.lastSyncAt ? new Date(fitbit.lastSyncAt).toLocaleString("de-DE") : "noch nicht synchronisiert"}
</p>
</div>
</div>
) : (
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-7 w-7 text-amber-600" />
<div>
<p className="text-sm font-black text-gray-950">OAuth-Verbindung ist der nächste technische Schritt</p>
<p className="mt-1 text-xs font-bold text-gray-400">
Die Tabellen und API-Struktur sind vorbereitet. Für den echten Login fehlen noch Fitbit Client-ID, Secret und Callback-Route.
</p>
</div>
</div>
)}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<button type="button" disabled 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-40">
<DevicePhoneMobileIcon className="h-5 w-5" />
Fitbit verbinden
</button>
<button type="button" disabled className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-100 px-4 py-4 text-sm font-black text-gray-950 disabled:opacity-50">
<ArrowPathIcon className="h-5 w-5" />
Jetzt synchronisieren
</button>
</div>
</div>
<aside 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" />
<h2 className="text-xl font-black text-gray-950">Geplanter Import</h2>
</div>
<div className="mt-5 space-y-3">
{plannedImports.map((item) => (
<div key={item} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
<CheckCircleIcon className="h-5 w-5 text-emerald-600" />
<span className="text-sm font-bold text-gray-600">{item}</span>
</div>
))}
</div>
</aside>
</section>
</div>
);
}

View file

@ -0,0 +1,165 @@
"use client";
import { ArrowPathIcon, CalendarDaysIcon, FireIcon, SparklesIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { todayKey } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
function fmt(value: number) {
return Math.round(value).toLocaleString("de-DE");
}
function prognosis(balance: number) {
if (balance >= 1000) return { label: "entspannt im Ziel", color: "text-emerald-700 bg-emerald-50" };
if (balance >= -300) return { label: "gut steuerbar", color: "text-blue-700 bg-blue-50" };
return { label: "leicht drüber - flexibel ausgleichen", color: "text-amber-700 bg-amber-50" };
}
export default function WochenbudgetPage() {
const utils = trpc.useUtils();
const [selectedDate, setSelectedDate] = useState(todayKey());
const [extraDate, setExtraDate] = useState(todayKey());
const [extraCalories, setExtraCalories] = useState("800");
const [saved, setSaved] = useState(false);
const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: selectedDate });
const summary = summaryQuery.data?.week ?? null;
const prediction = summary ? prognosis(summary.balance) : null;
const upsertPlanMutation = trpc.health.upsertWeeklyCaloriePlan.useMutation({
onSuccess: async () => {
await utils.health.statsSummary.invalidate({ referenceDate: selectedDate });
setSaved(true);
window.setTimeout(() => setSaved(false), 1500);
},
});
const dayByDate = useMemo(() => new Map((summary?.days ?? []).map((day) => [day.date, day])), [summary?.days]);
function updatePlan(date: string, plannedCalories: number, note?: string) {
upsertPlanMutation.mutate({
date,
plannedCalories: Math.max(800, Math.min(6000, Math.round(plannedCalories))),
note: note ?? dayByDate.get(date)?.note ?? null,
});
}
function applyRestaurantDistribution() {
if (!summary) return;
const extra = Math.max(0, Number(extraCalories) || 0);
const days = summary.days;
const targetDay = days.find((day) => day.date === extraDate);
if (!targetDay) return;
const otherDays = days.filter((day) => day.date !== extraDate);
const savingPerOtherDay = otherDays.length ? Math.ceil(extra / otherDays.length) : 0;
updatePlan(extraDate, targetDay.plannedCalories + extra, targetDay.note ?? "Restaurant / höherer Tag");
for (const day of otherDays) {
updatePlan(day.date, Math.max(1200, day.plannedCalories - savingPerOtherDay), day.note);
}
}
if (!summary || !prediction) {
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black text-gray-400">Wochenbudget wird geladen.</p>
</section>
</div>
);
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-black uppercase text-[#0F766E]">Flexible Planung</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Wochenbudget</h1>
<p className="mt-3 max-w-2xl text-sm font-semibold text-gray-400">
Ein höherer Tag ist kein Problem. Hellth Hub verteilt den Ausgleich freundlich auf die Woche.
</p>
</div>
<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>
</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">
<FireIcon className="h-8 w-8 text-orange-500" />
<p className="mt-3 text-3xl font-black text-gray-950">{fmt(summary.baseBudget)}</p>
<p className="text-sm font-bold text-gray-400">Wochenziel</p>
</div>
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<CalendarDaysIcon className="h-8 w-8 text-blue-500" />
<p className="mt-3 text-3xl font-black text-gray-950">{fmt(summary.eatenCalories)}</p>
<p className="text-sm font-bold text-gray-400">gegessen/geplant</p>
</div>
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<SparklesIcon className="h-8 w-8 text-emerald-500" />
<p className="mt-3 text-3xl font-black text-gray-950">+{fmt(summary.activityCalories)}</p>
<p className="text-sm font-bold text-gray-400">Sportbonus</p>
</div>
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className={`inline-flex rounded-full px-3 py-1 text-xs font-black ${prediction.color}`}>{prediction.label}</div>
<p className="mt-3 text-3xl font-black text-gray-950">{fmt(summary.availableCalories)}</p>
<p className="text-sm font-bold text-gray-400">noch verfügbar</p>
</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">Tagesverteilung</h2>
<div className="mt-5 grid gap-3">
{summary.days.map((day) => {
const date = new Date(`${day.date}T12:00:00`);
const over = day.eatenCalories > day.plannedCalories + day.activityCalories;
return (
<div key={day.date} className="grid gap-3 rounded-3xl bg-gray-50 p-4 md:grid-cols-[130px_1fr_160px_120px] md:items-center">
<div>
<p className="text-lg font-black text-gray-950">{date.toLocaleDateString("de-DE", { weekday: "short" })}</p>
<p className="text-xs font-bold text-gray-400">{date.toLocaleDateString("de-DE")}</p>
</div>
<div>
<div className="h-3 overflow-hidden rounded-full bg-white">
<div className={`h-full rounded-full ${over ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${Math.min(100, (day.eatenCalories / Math.max(1, day.plannedCalories + day.activityCalories)) * 100)}%` }} />
</div>
<p className="mt-2 text-sm font-bold text-gray-400">
{fmt(day.eatenCalories)} / {fmt(day.plannedCalories)} kcal
{day.activityCalories ? ` + ${fmt(day.activityCalories)} Sport` : ""}
{day.note ? ` - ${day.note}` : ""}
</p>
</div>
<input
type="number"
value={day.plannedCalories}
onChange={(event) => updatePlan(day.date, Number(event.target.value) || summary.dailyGoal, day.note)}
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold"
/>
<p className={`text-sm font-black ${day.balance >= 0 ? "text-emerald-700" : "text-amber-700"}`}>
{day.balance >= 0 ? "+" : ""}{fmt(day.balance)} kcal
</p>
</div>
);
})}
</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">Restaurantbesuch einplanen</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">
Beispiel: Samstag +800 kcal. Die App verteilt den Ausgleich auf die restlichen Tage.
</p>
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_160px_180px]">
<input type="date" value={extraDate} onChange={(event) => setExtraDate(event.target.value)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold" />
<input value={extraCalories} onChange={(event) => setExtraCalories(event.target.value)} placeholder="+ kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold" />
<button type="button" onClick={applyRestaurantDistribution} disabled={upsertPlanMutation.isPending} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40">
<ArrowPathIcon className="h-5 w-5" />
Verteilen
</button>
</div>
</section>
{saved ? <div className="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-emerald-600 px-5 py-3 text-sm font-black text-white shadow-lg">Gespeichert</div> : null}
</div>
);
}

View file

@ -0,0 +1,142 @@
"use client";
import { BeakerIcon, ClockIcon, FireIcon, ScaleIcon } from "@heroicons/react/24/outline";
import { useEffect, useState } from "react";
import { trpc } from "@/lib/trpc";
type GoalsForm = {
calories: number;
proteinG: number;
carbsG: number;
fatG: number;
drinkMl: number;
weightKg: number;
fastingHours: number;
weeklyCalories: number;
sportAddsCalories: boolean;
};
const defaultGoals: GoalsForm = {
calories: 2000,
proteinG: 150,
carbsG: 200,
fatG: 67,
drinkMl: 2500,
weightKg: 120,
fastingHours: 12,
weeklyCalories: 14000,
sportAddsCalories: true,
};
const fields: Array<{ key: keyof Omit<GoalsForm, "sportAddsCalories">; label: string; unit: string; icon: typeof FireIcon }> = [
{ key: "calories", label: "Kalorien", unit: "kcal", icon: FireIcon },
{ key: "weeklyCalories", label: "Wochenbudget", unit: "kcal", icon: FireIcon },
{ key: "proteinG", label: "Protein", unit: "g", icon: ScaleIcon },
{ key: "carbsG", label: "Kohlenhydrate", unit: "g", icon: ScaleIcon },
{ key: "fatG", label: "Fett", unit: "g", icon: ScaleIcon },
{ key: "drinkMl", label: "Trinkziel", unit: "ml", icon: BeakerIcon },
{ key: "weightKg", label: "Gewichtsziel", unit: "kg", icon: ScaleIcon },
{ key: "fastingHours", label: "Fasten", unit: "h", icon: ClockIcon },
];
function todayKey() {
return new Date().toISOString().slice(0, 10);
}
export default function ZielePage() {
const utils = trpc.useUtils();
const overviewQuery = trpc.health.overview.useQuery({ date: todayKey() });
const saveGoalsMutation = trpc.health.upsertGoals.useMutation({
onSuccess: async () => {
await utils.health.overview.invalidate();
setSaved(true);
window.setTimeout(() => setSaved(false), 1600);
},
});
const [goals, setGoals] = useState<GoalsForm>(defaultGoals);
const [saved, setSaved] = useState(false);
useEffect(() => {
const apiGoals = overviewQuery.data?.goals;
if (!apiGoals) return;
const calories = apiGoals.calories ?? defaultGoals.calories;
setGoals({
calories,
proteinG: apiGoals.proteinG ?? defaultGoals.proteinG,
carbsG: apiGoals.carbsG ?? defaultGoals.carbsG,
fatG: apiGoals.fatG ?? defaultGoals.fatG,
drinkMl: apiGoals.drinkMl ?? defaultGoals.drinkMl,
weightKg: Number(apiGoals.weightKg ?? defaultGoals.weightKg),
fastingHours: apiGoals.fastingHours ?? defaultGoals.fastingHours,
weeklyCalories: apiGoals.weeklyCalories ?? calories * 7,
sportAddsCalories: apiGoals.sportAddsCalories ?? true,
});
}, [overviewQuery.data?.goals]);
function saveGoals() {
saveGoalsMutation.mutate({
drinkMl: goals.drinkMl,
calories: goals.calories,
carbsG: goals.carbsG,
fatG: goals.fatG,
proteinG: goals.proteinG,
weightKg: goals.weightKg || null,
fastingHours: goals.fastingHours,
weeklyCalories: goals.weeklyCalories || goals.calories * 7,
sportAddsCalories: goals.sportAddsCalories,
});
}
return (
<div className="mx-auto max-w-4xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Health-API</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Ziele</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Alltagstaugliche Zielwerte statt perfekter Wissenschaft. Diese Werte werden jetzt serverseitig pro Benutzer gespeichert.
</p>
</header>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
{overviewQuery.isLoading ? (
<div className="rounded-3xl bg-gray-50 p-5 text-sm font-bold text-gray-400">Ziele werden geladen...</div>
) : null}
<div className="divide-y divide-gray-100">
{fields.map((field) => (
<label key={field.key} className="grid gap-3 py-4 sm:grid-cols-[44px_1fr_180px] sm:items-center">
<field.icon className="h-8 w-8 text-[#0F766E]" />
<span className="text-xl font-black text-gray-950">{field.label}</span>
<div className="grid grid-cols-[1fr_52px] items-center gap-2">
<input
type="number"
value={goals[field.key]}
onChange={(event) => setGoals((current) => ({ ...current, [field.key]: Number(event.target.value) || 0 }))}
className="rounded-2xl border border-gray-200 px-4 py-3 text-right text-sm font-bold"
/>
<span className="text-sm font-black text-gray-400">{field.unit}</span>
</div>
</label>
))}
</div>
<label className="mt-4 flex items-center justify-between gap-3 rounded-3xl bg-gray-50 px-4 py-4 text-sm font-black text-gray-950">
Sportkalorien erhöhen das Tagesbudget
<input
type="checkbox"
checked={goals.sportAddsCalories}
onChange={(event) => setGoals((current) => ({ ...current, sportAddsCalories: event.target.checked }))}
/>
</label>
<button
type="button"
onClick={saveGoals}
disabled={saveGoalsMutation.isPending}
className="mt-6 w-full rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
>
{saveGoalsMutation.isPending ? "Speichere..." : "Ziele speichern"}
</button>
{saved ? <p className="mt-3 text-center text-sm font-bold text-green-700">Gespeichert.</p> : null}
{saveGoalsMutation.error ? <p className="mt-3 text-center text-sm font-bold text-red-600">{saveGoalsMutation.error.message}</p> : null}
</section>
</div>
);
}

View file

@ -0,0 +1,109 @@
"use client";
import { useSession } from "@/lib/auth-client";
import { hasCompletedHealthProfile } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
const PLATFORM_ALLOWED_PREFIXES = [
"/",
"/aktivitaeten",
"/benutzer",
"/einstellungen",
"/erinnerungen",
"/hellfireclub",
"/einkaufsliste",
"/lebensmittel",
"/maerkte",
"/onboarding",
"/planung",
"/rezepte",
"/ziele",
"/wochenbudget",
"/sicherheit/2fa-setup",
];
export function AuthGuard({ children }: { children: React.ReactNode }) {
const { data: session, isPending } = useSession();
const router = useRouter();
const pathname = usePathname();
const platformAdminEmails = (
process.env.NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS ?? "admin@onl1.eu"
)
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean);
const userRole = ((session?.user as { role?: string } | undefined)?.role ?? "").toLowerCase();
const userEmail = (session?.user?.email ?? "").toLowerCase();
const isPlatformAdmin =
userRole === "admin" ||
userRole === "platform_admin" ||
platformAdminEmails.includes(userEmail);
const securityQuery = trpc.security.status.useQuery(undefined, {
enabled: !!session && !isPending,
retry: false,
});
const requiresTwoFactorSetup = securityQuery.data?.requiresTwoFactorSetup ?? false;
useEffect(() => {
if (!isPending && !session) {
router.push("/login");
return;
}
if (!isPending && session) {
if (securityQuery.isLoading) return;
if (requiresTwoFactorSetup && !pathname.startsWith("/sicherheit/2fa-setup")) {
router.push("/sicherheit/2fa-setup");
return;
}
if (isPlatformAdmin) {
const isAllowed = PLATFORM_ALLOWED_PREFIXES.some((prefix) =>
prefix === "/" ? pathname === "/" : pathname.startsWith(prefix),
);
if (!isAllowed) {
router.push("/");
return;
}
} else {
const userKey = session.user.id ?? session.user.email ?? null;
const onboardingComplete = hasCompletedHealthProfile(userKey);
if (!onboardingComplete && !pathname.startsWith("/onboarding")) {
router.push("/onboarding");
return;
}
if (pathname.startsWith("/benutzer") || pathname.startsWith("/einstellungen")) {
router.push("/");
}
}
}
}, [
session,
isPending,
isPlatformAdmin,
pathname,
router,
requiresTwoFactorSetup,
securityQuery.isLoading,
]);
if (isPending || (!!session && securityQuery.isLoading)) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-[#0F766E] border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-gray-400">Laden...</p>
</div>
</div>
);
}
if (!session) return null;
return <>{children}</>;
}

View file

@ -0,0 +1,22 @@
import { PageHeader } from "./page-header";
interface DataPageLayoutProps {
title: string;
description?: string;
action?: React.ReactNode;
children: React.ReactNode;
}
export function DataPageLayout({
title,
description,
action,
children,
}: DataPageLayoutProps) {
return (
<div className="space-y-6">
<PageHeader title={title} description={description} action={action} />
{children}
</div>
);
}

View file

@ -0,0 +1,9 @@
interface EmptyStateProps {
message: string;
}
export function EmptyState({ message }: EmptyStateProps) {
return (
<div className="py-16 text-center text-sm text-gray-400">{message}</div>
);
}

View file

@ -0,0 +1,20 @@
import { ReactNode } from "react";
type FormFieldProps = {
label: string;
hint?: string;
children: ReactNode;
className?: string;
};
export function FormField({ label, hint, children, className = "" }: FormFieldProps) {
return (
<label className={`block space-y-1.5 ${className}`}>
<span className="block text-xs font-black uppercase text-gray-400">{label}</span>
{children}
{hint ? <span className="block text-xs font-bold text-gray-400">{hint}</span> : null}
</label>
);
}
export const inputClassName = "w-full rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-semibold text-gray-950 outline-none transition focus:border-[#0F766E] focus:ring-2 focus:ring-[#0F766E]/10 disabled:bg-gray-50 disabled:text-gray-400";

View file

@ -0,0 +1,31 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
const HELP_BASE_URL = (process.env.NEXT_PUBLIC_HELP_URL ?? "").replace(/\/$/, "");
function guessHelpTopicFromPath(pathname: string) {
const segment = pathname.split("/").filter(Boolean)[0];
return segment || "dashboard";
}
export function HelpLink({ topicOverride }: { topicOverride?: string }) {
const pathname = usePathname();
const topic = topicOverride ?? guessHelpTopicFromPath(pathname || "/");
const href = HELP_BASE_URL ? `${HELP_BASE_URL}/t/${encodeURIComponent(topic)}` : `/einstellungen`;
return (
<Link
href={href}
target={HELP_BASE_URL ? "_blank" : undefined}
rel={HELP_BASE_URL ? "noreferrer" : undefined}
title="Hilfe zu diesem Bereich"
aria-label="Hilfe zu diesem Bereich"
className="inline-flex items-center justify-center rounded-md border border-gray-200 bg-white p-1.5 text-gray-500 transition-colors hover:border-[#0F766E] hover:text-[#0F766E]"
>
<QuestionMarkCircleIcon className="h-5 w-5" />
</Link>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import {
CalendarDaysIcon,
BellAlertIcon,
BoltIcon,
ChartBarIcon,
Cog6ToothIcon,
FireIcon,
SparklesIcon,
Squares2X2Icon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { usePathname } from "next/navigation";
const items = [
{ href: "/", label: "Tag", icon: CalendarDaysIcon },
{ href: "/planung", label: "Plan", icon: SparklesIcon },
{ href: "/statistiken", label: "Stats", icon: ChartBarIcon },
{ href: "/hellfireclub", label: "Club", icon: FireIcon },
];
export function MobileNav() {
const pathname = usePathname();
return (
<>
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-gray-200 bg-white/95 px-4 py-3 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/95 lg:hidden">
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#0F766E] text-xs font-bold text-white">
HH
</div>
<div>
<p className="text-sm font-semibold leading-none text-gray-900 dark:text-neutral-100">Hellth Hub</p>
<p className="mt-0.5 text-xs text-gray-400">Daily Health</p>
</div>
</div>
</div>
<nav className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-gray-200 bg-white/95 px-2 pb-[env(safe-area-inset-bottom)] pt-1 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/95 lg:hidden">
{items.map((item) => {
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return (
<Link
key={`${item.href}-${item.label}`}
href={item.href}
className={`flex min-h-14 flex-col items-center justify-center gap-0.5 rounded-md text-xs font-medium ${
active ? "text-[#0F766E]" : "text-gray-500 dark:text-neutral-400"
}`}
>
<item.icon className="h-5 w-5" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</>
);
}

View file

@ -0,0 +1,141 @@
"use client";
import { BlockNoteView } from "@blocknote/mantine";
import { de } from "@blocknote/core/locales";
import { useCreateBlockNote } from "@blocknote/react";
import { useEffect } from "react";
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002";
type NewsBlockNoteEditorProps = {
label?: string;
value: string;
placeholder?: string;
minHeightClassName?: string;
ensureLeadingHeading?: boolean;
onChange: (next: string) => void;
};
function normalizeHtml(html: string) {
const trimmed = html.trim();
if (!trimmed) return "";
if (trimmed === "<p></p>" || trimmed === "<p><br></p>") return "";
return trimmed;
}
function plainTextFromHtml(html: string) {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
async function uploadEditorImage(file: File) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${API_URL}/api/uploads/image`, {
method: "POST",
credentials: "include",
body: formData,
});
const payload = (await response.json().catch(() => ({}))) as { url?: string; error?: string };
if (!response.ok || !payload.url) {
throw new Error(payload.error ?? "Bild-Upload fehlgeschlagen");
}
return payload.url;
}
export function NewsBlockNoteEditor({
label,
value,
placeholder = "Hier den Beitrag schreiben...",
minHeightClassName = "min-h-[480px]",
ensureLeadingHeading = false,
onChange,
}: NewsBlockNoteEditorProps) {
const editor = useCreateBlockNote({
dictionary: {
...de,
placeholders: {
...(de as { placeholders?: Record<string, string> }).placeholders,
default: placeholder,
emptyDocument: placeholder,
},
},
uploadFile: uploadEditorImage,
});
useEffect(() => {
const currentHtml = normalizeHtml(editor.blocksToHTMLLossy(editor.document));
const targetHtml = normalizeHtml(value || "");
if (currentHtml === targetHtml) return;
const starterBlocks = [
{
type: "heading",
props: { level: 1 },
},
{
type: "paragraph",
},
] as never;
try {
const blocks = targetHtml ? editor.tryParseHTMLToBlocks(targetHtml) : [];
if (blocks.length > 0) {
editor.replaceBlocks(editor.document, blocks);
return;
}
const fallbackText = plainTextFromHtml(targetHtml);
if (!fallbackText) {
editor.replaceBlocks(editor.document, ensureLeadingHeading ? starterBlocks : []);
return;
}
editor.replaceBlocks(editor.document, [
{
type: "paragraph",
content: fallbackText,
} as never,
]);
} catch {
const fallbackText = plainTextFromHtml(targetHtml);
if (!fallbackText) {
if (ensureLeadingHeading) {
editor.replaceBlocks(editor.document, starterBlocks);
}
return;
}
editor.replaceBlocks(editor.document, [
{
type: "paragraph",
content: fallbackText,
} as never,
]);
}
}, [editor, ensureLeadingHeading, value]);
return (
<div>
{label ? <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label> : null}
<div
className={`rounded-md border border-gray-200 overflow-hidden bg-white ${minHeightClassName}`}
>
<BlockNoteView
editor={editor}
theme="light"
onChange={() => {
const html = normalizeHtml(editor.blocksToHTMLLossy(editor.document));
onChange(html);
}}
slashMenu
formattingToolbar
sideMenu
tableHandles
className="bg-white"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { HelpLink } from "./help-link";
interface PageHeaderProps {
title: string;
description?: string;
action?: React.ReactNode;
helpTopic?: string;
}
export function PageHeader({ title, description, action, helpTopic }: PageHeaderProps) {
return (
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{title}</h1>
{description && (
<p className="text-sm text-gray-500 dark:text-neutral-400 mt-1">{description}</p>
)}
</div>
<div className="flex items-center gap-2">
{action ? <div>{action}</div> : null}
<HelpLink topicOverride={helpTopic} />
</div>
</div>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { useState } from "react";
import superjson from "superjson";
import { trpc } from "@/lib/trpc";
import { ThemeProvider } from "./theme-provider";
function resolveApiUrl() {
const configured = process.env.NEXT_PUBLIC_API_URL;
if (configured) return configured;
if (process.env.NODE_ENV === "production") {
throw new Error("NEXT_PUBLIC_API_URL muss in Produktion gesetzt sein");
}
return "http://localhost:3002";
}
const API_URL = resolveApiUrl();
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
},
})
);
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${API_URL}/api/trpc`,
transformer: superjson,
fetch: (url, opts) => {
const headers = new Headers(opts?.headers ?? {});
return fetch(url, {
...opts,
credentials: "include",
headers,
});
},
}),
],
})
);
return (
<MantineProvider
defaultColorScheme="light"
theme={{
primaryColor: "teal",
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
}}
>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
</trpc.Provider>
</MantineProvider>
);
}

View file

@ -0,0 +1,86 @@
"use client";
import { useEffect, useRef } from "react";
import { useSession } from "@/lib/auth-client";
import { ReminderKind, loadHellthData } from "@/lib/hellth-data";
const labels: Record<ReminderKind, string> = {
water: "Wasser",
snack: "Snack",
movement: "Bewegung",
};
function minutesOfDay(value: string) {
const [hours = "0", minutes = "0"] = value.split(":");
return Number(hours) * 60 + Number(minutes);
}
function canNotifyNow(startTime: string, endTime: string) {
const now = new Date();
const current = now.getHours() * 60 + now.getMinutes();
return current >= minutesOfDay(startTime) && current <= minutesOfDay(endTime);
}
async function showReminder(kind: ReminderKind, message: string) {
if (typeof window === "undefined" || !("Notification" in window)) return;
if (Notification.permission !== "granted") return;
const title = `Hellth Hub: ${labels[kind]}`;
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, {
body: message,
tag: `hellth-${kind}`,
badge: "/icon-192.png",
});
return;
} catch {
// Fall through to regular notification.
}
}
new Notification(title, { body: message, tag: `hellth-${kind}` });
}
export function ReminderRunner() {
const { data: session } = useSession();
const userKey = session?.user?.id ?? session?.user?.email ?? null;
const lastSentRef = useRef<Record<string, number>>({});
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
void navigator.serviceWorker.register("/hellth-sw.js").catch(() => undefined);
}, []);
useEffect(() => {
if (!userKey) return;
function tick() {
const data = loadHellthData(userKey);
const reminders = data.reminders;
if (!reminders.enabled) return;
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
const now = Date.now();
(Object.keys(reminders.rules) as ReminderKind[]).forEach((kind) => {
const rule = reminders.rules[kind];
if (!rule.enabled) return;
if (!canNotifyNow(rule.startTime, rule.endTime)) return;
const lastSentKey = `${userKey}:${kind}`;
const lastSent = lastSentRef.current[lastSentKey] ?? 0;
if (now - lastSent < rule.intervalMinutes * 60_000) return;
lastSentRef.current[lastSentKey] = now;
void showReminder(kind, rule.message);
});
}
tick();
const interval = window.setInterval(tick, 60_000);
return () => window.clearInterval(interval);
}, [userKey]);
return null;
}

View file

@ -0,0 +1,459 @@
"use client";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { Table } from "@tiptap/extension-table";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import TableRow from "@tiptap/extension-table-row";
import TextAlign from "@tiptap/extension-text-align";
import Underline from "@tiptap/extension-underline";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useMemo, useRef, useState } from "react";
type EditorMode = "basic" | "news";
type RichTextEditorProps = {
label: string;
value: string;
placeholder?: string;
minHeightClassName?: string;
mode?: EditorMode;
onChange: (next: string) => void;
};
type BlockAction = {
id: string;
label: string;
run: () => void;
};
type SlashMenuState = {
open: boolean;
top: number;
left: number;
slashPos: number | null;
};
function normalizeHtml(html: string) {
const trimmed = html.trim();
if (!trimmed) return "";
if (trimmed === "<br>" || trimmed === "<p><br></p>") return "";
return trimmed;
}
function buttonClass(active = false) {
return `px-2 py-1 rounded text-xs transition-colors ${
active ? "bg-[#ECFDF5] text-[#0F766E]" : "text-gray-700 hover:bg-gray-200"
}`;
}
export function RichTextEditor({
label,
value,
placeholder,
minHeightClassName = "min-h-[180px]",
mode = "basic",
onChange,
}: RichTextEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [blockMenuOpen, setBlockMenuOpen] = useState(false);
const [slashMenu, setSlashMenu] = useState<SlashMenuState>({
open: false,
top: 0,
left: 0,
slashPos: null,
});
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: {
rel: "noopener noreferrer",
target: "_blank",
},
}),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Placeholder.configure({
placeholder: placeholder ?? "Inhalt schreiben...",
}),
Table.configure({
resizable: true,
HTMLAttributes: { class: "nf-editor-table" },
}),
TableRow,
TableHeader,
TableCell,
],
content: value || "",
immediatelyRender: false,
onUpdate: ({ editor: ed }) => {
onChange(normalizeHtml(ed.getHTML()));
},
});
const blockActions = useMemo<BlockAction[]>(() => {
if (!editor) return [];
return [
{ id: "paragraph", label: "Absatz", run: () => editor.chain().focus().setParagraph().run() },
{ id: "h2", label: "Überschrift H2", run: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
{ id: "h3", label: "Überschrift H3", run: () => editor.chain().focus().toggleHeading({ level: 3 }).run() },
{ id: "bullet", label: "Aufzählung", run: () => editor.chain().focus().toggleBulletList().run() },
{ id: "ordered", label: "Nummerierte Liste", run: () => editor.chain().focus().toggleOrderedList().run() },
{ id: "quote", label: "Zitat", run: () => editor.chain().focus().toggleBlockquote().run() },
{
id: "table",
label: "Tabelle (3x3)",
run: () =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
];
}, [editor]);
useEffect(() => {
if (!editor) return;
const normalized = value || "";
if (editor.getHTML() !== normalized) {
editor.commands.setContent(normalized, { emitUpdate: false });
}
}, [editor, value]);
useEffect(() => {
if (!editor || mode !== "news") return;
const onKeyDown = (event: KeyboardEvent) => {
if (!editor.isFocused) return;
if (event.key === "Escape") {
setSlashMenu((prev) => ({ ...prev, open: false }));
return;
}
if (event.key !== "/") return;
setTimeout(() => {
if (!containerRef.current) return;
const pos = editor.state.selection.from;
const coords = editor.view.coordsAtPos(pos);
const containerRect = containerRef.current.getBoundingClientRect();
setSlashMenu({
open: true,
top: coords.bottom - containerRect.top + 6,
left: Math.max(8, coords.left - containerRect.left),
slashPos: pos,
});
}, 0);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [editor, mode]);
useEffect(() => {
function onClickOutside(event: MouseEvent) {
if (!containerRef.current) return;
if (!containerRef.current.contains(event.target as Node)) {
setBlockMenuOpen(false);
setSlashMenu((prev) => ({ ...prev, open: false }));
}
}
window.addEventListener("mousedown", onClickOutside);
return () => window.removeEventListener("mousedown", onClickOutside);
}, []);
function applyBlockAction(action: BlockAction) {
if (!editor) return;
if (slashMenu.open && slashMenu.slashPos) {
editor
.chain()
.focus()
.deleteRange({ from: slashMenu.slashPos - 1, to: slashMenu.slashPos })
.run();
}
action.run();
setBlockMenuOpen(false);
setSlashMenu((prev) => ({ ...prev, open: false, slashPos: null }));
}
function handleLink() {
if (!editor) return;
const url = window.prompt("Link-URL eingeben (https://...)");
if (url === null) return;
const trimmedUrl = url.trim();
if (!trimmedUrl) {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: trimmedUrl }).run();
}
if (!editor) {
return null;
}
const showNewsBlockFeatures = mode === "news";
return (
<div ref={containerRef} className="relative">
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<div className="border border-gray-200 rounded-md overflow-hidden bg-white">
<div className="flex flex-wrap items-center gap-1 border-b border-gray-200 bg-gray-50 px-2 py-1.5">
{showNewsBlockFeatures && (
<div className="relative">
<button
type="button"
onClick={() => setBlockMenuOpen((value) => !value)}
className="px-2 py-1 rounded text-xs font-medium text-gray-700 hover:bg-gray-200"
title="Block-Menü"
>
Block +
</button>
{blockMenuOpen && (
<div className="absolute top-8 left-0 z-20 w-56 rounded-md border border-gray-200 bg-white shadow-lg p-1">
{blockActions.map((action) => (
<button
key={action.id}
type="button"
onClick={() => applyBlockAction(action)}
className="w-full text-left px-3 py-2 text-sm rounded hover:bg-gray-100"
>
{action.label}
</button>
))}
</div>
)}
</div>
)}
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={`${buttonClass(editor.isActive("bold"))} font-semibold`}
title="Fett"
>
B
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`${buttonClass(editor.isActive("italic"))} italic`}
title="Kursiv"
>
I
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={`${buttonClass(editor.isActive("underline"))} underline`}
title="Unterstrichen"
>
U
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleStrike().run()}
className={`${buttonClass(editor.isActive("strike"))} line-through`}
title="Durchgestrichen"
>
S
</button>
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().setParagraph().run()}
className={buttonClass(editor.isActive("paragraph"))}
title="Absatz"
>
P
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={buttonClass(editor.isActive("heading", { level: 2 }))}
title="Überschrift 2"
>
H2
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={buttonClass(editor.isActive("heading", { level: 3 }))}
title="Überschrift 3"
>
H3
</button>
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={buttonClass(editor.isActive("bulletList"))}
title="Aufzählung"
>
Liste
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={buttonClass(editor.isActive("orderedList"))}
title="Nummerierte Liste"
>
1.
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={buttonClass(editor.isActive("blockquote"))}
title="Zitat"
>
Quote
</button>
{showNewsBlockFeatures && (
<>
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
className={buttonClass(editor.isActive("table"))}
title="Tabelle einfügen"
>
Tabelle
</button>
<button
type="button"
onClick={() => editor.chain().focus().addRowAfter().run()}
className={buttonClass(false)}
title="Zeile hinzufügen"
>
+Zeile
</button>
<button
type="button"
onClick={() => editor.chain().focus().addColumnAfter().run()}
className={buttonClass(false)}
title="Spalte hinzufügen"
>
+Spalte
</button>
<button
type="button"
onClick={() => editor.chain().focus().deleteTable().run()}
className={buttonClass(false)}
title="Tabelle entfernen"
>
-Tabelle
</button>
</>
)}
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={handleLink}
className={buttonClass(editor.isActive("link"))}
title="Link einfügen"
>
Link
</button>
<button
type="button"
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
className={buttonClass(false)}
title="Formatierung entfernen"
>
Reset
</button>
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign("left").run()}
className={buttonClass(editor.isActive({ textAlign: "left" }))}
title="Linksbündig"
>
L
</button>
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign("center").run()}
className={buttonClass(editor.isActive({ textAlign: "center" }))}
title="Zentriert"
>
C
</button>
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign("right").run()}
className={buttonClass(editor.isActive({ textAlign: "right" }))}
title="Rechtsbündig"
>
R
</button>
<span className="w-px h-4 bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
className={`${buttonClass(false)} disabled:opacity-40`}
title="Rückgängig"
>
Undo
</button>
<button
type="button"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
className={`${buttonClass(false)} disabled:opacity-40`}
title="Wiederholen"
>
Redo
</button>
</div>
<EditorContent
editor={editor}
className={`w-full px-3 py-2 text-sm rounded-b-md focus-within:ring-1 focus-within:ring-[#0F766E] ${minHeightClassName} [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[inherit] [&_.ProseMirror_h2]:text-xl [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h3]:text-lg [&_.ProseMirror_h3]:font-semibold [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:ml-5 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:ml-5 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-gray-300 [&_.ProseMirror_blockquote]:pl-3 [&_.ProseMirror_a]:text-[#0F766E] [&_.ProseMirror_a]:underline [&_.ProseMirror_table]:w-full [&_.ProseMirror_table]:border-collapse [&_.ProseMirror_table]:border [&_.ProseMirror_td]:border [&_.ProseMirror_td]:border-gray-300 [&_.ProseMirror_td]:p-2 [&_.ProseMirror_th]:border [&_.ProseMirror_th]:border-gray-300 [&_.ProseMirror_th]:p-2 [&_.ProseMirror_th]:bg-gray-100`}
/>
</div>
{showNewsBlockFeatures && slashMenu.open && (
<div
className="absolute z-30 w-60 rounded-md border border-gray-200 bg-white shadow-lg p-1"
style={{ top: slashMenu.top, left: slashMenu.left }}
>
<p className="px-3 py-1 text-[11px] text-gray-500">Slash-Menü</p>
{blockActions.map((action) => (
<button
key={`slash-${action.id}`}
type="button"
onClick={() => applyBlockAction(action)}
className="w-full text-left px-3 py-2 text-sm rounded hover:bg-gray-100"
>
{action.label}
</button>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,263 @@
"use client";
import {
ArrowRightStartOnRectangleIcon,
CalendarDaysIcon,
ChartBarIcon,
BellAlertIcon,
BoltIcon,
ChevronRightIcon,
ChevronUpDownIcon,
Cog6ToothIcon,
DevicePhoneMobileIcon,
FireIcon,
MoonIcon,
ScaleIcon,
ShoppingCartIcon,
SparklesIcon,
SunIcon,
UserIcon,
UsersIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { signOut, useSession } from "@/lib/auth-client";
import { useTheme } from "./theme-provider";
type NavItem = {
href?: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
disabled?: boolean;
hint?: string;
};
type NavGroup = {
label: string;
items: NavItem[];
};
const navGroups: NavGroup[] = [
{
label: "Heute",
items: [
{ href: "/", label: "Tagesansicht", icon: CalendarDaysIcon },
{ href: "/wochenbudget", label: "Wochenbudget", icon: ChartBarIcon },
{ href: "/statistiken", label: "Statistiken", icon: ChartBarIcon },
{ href: "/erinnerungen", label: "Erinnerungen", icon: BellAlertIcon },
{ href: "/aktivitaeten", label: "Aktivitäten", icon: BoltIcon },
{ href: "/wearables", label: "Wearables", icon: DevicePhoneMobileIcon },
{ href: "/hellfireclub", label: "Hellfireclub", icon: FireIcon },
{ href: "/gewicht", label: "Gewicht", icon: ScaleIcon },
{ href: "/ziele", label: "Ziele", icon: ChartBarIcon },
],
},
{
label: "Ernährung",
items: [
{ href: "/planung", label: "Essensplanung", icon: SparklesIcon },
{ href: "/lebensmittel", label: "Lebensmittel", icon: ShoppingCartIcon },
{ href: "/maerkte", label: "Märkte", icon: ShoppingCartIcon },
{ href: "/rezepte", label: "Rezepte", icon: SparklesIcon },
{ href: "/rezepte/pruefung", label: "Rezeptprüfung", icon: SparklesIcon },
{ href: "/einkaufsliste", label: "Einkaufsliste", icon: ShoppingCartIcon },
],
},
{
label: "System",
items: [
{ href: "/benutzer", label: "Benutzer", icon: UsersIcon },
{ href: "/aktivitaetsfeed", label: "Activity Feed", icon: BellAlertIcon },
{ href: "/einstellungen", label: "Einstellungen", icon: Cog6ToothIcon },
{ href: "/einstellungen/punkte", label: "Punktevergabe", icon: ChartBarIcon },
],
},
];
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const { data: session } = useSession();
const { isDark, toggleDark } = useTheme();
const [menuOpen, setMenuOpen] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
if (menuOpen) document.addEventListener("mousedown", handleOutside);
return () => document.removeEventListener("mousedown", handleOutside);
}, [menuOpen]);
function toggleGroup(label: string) {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}
const isActive = (href: string) => {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
};
async function handleSignOut() {
await signOut();
if (session?.user?.id) {
localStorage.removeItem(`hellth-theme:${session.user.id}`);
}
document.documentElement.classList.remove("dark");
router.push("/login");
}
return (
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 flex-col border-r border-gray-200 bg-white dark:border-neutral-800 dark:bg-neutral-900 lg:flex">
<div className="border-b border-gray-100 px-5 py-5 dark:border-neutral-800">
<div className="flex items-center gap-2">
<div className="flex h-[36.8px] w-[36.8px] items-center justify-center rounded-lg bg-[#0F766E] text-xs font-bold text-white">
HH
</div>
<div>
<p className="leading-none text-sm font-semibold text-gray-900 dark:text-neutral-100">Hellth Hub</p>
<p className="mt-0.5 text-xs text-gray-400">Home-Lab Health</p>
</div>
</div>
</div>
<nav className="flex-1 space-y-6 overflow-y-auto px-3 py-4">
{navGroups.map((group) => (
<div key={group.label}>
<button
type="button"
onClick={() => toggleGroup(group.label)}
className="mb-1.5 flex w-full items-center justify-between px-2 text-xs font-semibold uppercase tracking-wider text-gray-400 hover:text-gray-600 dark:hover:text-neutral-300"
>
{group.label}
<ChevronRightIcon className={`h-3 w-3 transition-transform ${!collapsedGroups.has(group.label) ? "rotate-90" : ""}`} />
</button>
{!collapsedGroups.has(group.label) ? (
<ul className="space-y-0.5">
{group.items.map((item) => {
if (item.disabled || !item.href) {
return (
<li key={`${group.label}-${item.label}`}>
<span className="group flex cursor-not-allowed items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-gray-300 dark:text-neutral-600">
<item.icon className="h-4.5 w-4.5 shrink-0" />
<span className="flex-1">{item.label}</span>
{item.hint ? <span className="text-[10px] uppercase">{item.hint}</span> : null}
</span>
</li>
);
}
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
href={item.href}
className={`group flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-all ${
active
? "bg-teal-50 text-[#0F766E] dark:bg-teal-950/40"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
}`}
>
<item.icon className={`h-4.5 w-4.5 shrink-0 ${active ? "text-[#0F766E]" : "text-gray-400 group-hover:text-gray-600"}`} />
<span className="flex-1">{item.label}</span>
{active ? <ChevronRightIcon className="h-3.5 w-3.5 text-[#0F766E]" /> : null}
</Link>
</li>
);
})}
</ul>
) : null}
</div>
))}
</nav>
<div className="mt-auto border-t border-gray-100 px-3 py-2 dark:border-neutral-800">
<div ref={menuRef} className="relative">
{menuOpen ? (
<div className="absolute bottom-full left-0 right-0 z-50 mb-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-neutral-800 dark:bg-neutral-900">
<div className="border-b border-gray-100 px-4 py-3 dark:border-neutral-800">
<p className="truncate text-sm font-semibold text-gray-900 dark:text-neutral-100">
{session?.user?.name ?? "Administrator"}
</p>
<p className="truncate text-xs text-gray-400">{session?.user?.email ?? ""}</p>
</div>
<div className="border-b border-gray-100 px-4 py-2.5 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
{isDark ? <SunIcon className="h-4 w-4 text-gray-400" /> : <MoonIcon className="h-4 w-4 text-gray-400" />}
<span className="text-sm text-gray-700 dark:text-neutral-300">
{isDark ? "Helles Design" : "Dunkles Design"}
</span>
</div>
<button
type="button"
onClick={toggleDark}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none ${
isDark ? "bg-[#0F766E]" : "bg-gray-200"
}`}
role="switch"
aria-checked={isDark}
aria-label="Dunkles Design umschalten"
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ${
isDark ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
<Link
href="/einstellungen"
onClick={() => setMenuOpen(false)}
className="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<Cog6ToothIcon className="h-4 w-4 text-gray-400" />
Einstellungen
</Link>
<button
type="button"
onClick={handleSignOut}
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<ArrowRightStartOnRectangleIcon className="h-4 w-4 text-gray-400" />
Abmelden
</button>
</div>
) : null}
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 hover:bg-gray-50 dark:hover:bg-neutral-800"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#0F766E] text-xs font-bold text-white">
<UserIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 text-left">
<p className="truncate text-xs font-medium text-gray-900 dark:text-neutral-100">
{session?.user?.name ?? "Administrator"}
</p>
<p className="truncate text-xs text-gray-400">{session?.user?.email ?? ""}</p>
</div>
<ChevronUpDownIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
</button>
</div>
</div>
</aside>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { trpc } from "@/lib/trpc";
type ThemeContextValue = { isDark: boolean; toggleDark: () => void };
const ThemeContext = createContext<ThemeContextValue>({
isDark: false,
toggleDark: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [isDark, setIsDark] = useState(false);
const [localInit, setLocalInit] = useState(false);
// 1. Start neutral; the user profile below owns the persisted theme.
useEffect(() => {
setLocalInit(true);
}, []);
// 2. Sync from DB once profile is loaded — overrides the localStorage value
const { data: profile } = trpc.users.myProfile.useQuery(undefined, {
enabled: localInit,
retry: false,
});
const setTheme = trpc.users.setTheme.useMutation();
useEffect(() => {
if (!profile) return;
const userThemeKey = `hellth-theme:${profile.id}`;
const dbTheme = profile.theme;
if (dbTheme === null || dbTheme === undefined) {
const stored = localStorage.getItem(userThemeKey);
const dark = stored === "dark";
setIsDark(dark);
document.documentElement.classList.toggle("dark", dark);
return;
}
const dark = dbTheme === "dark";
setIsDark(dark);
document.documentElement.classList.toggle("dark", dark);
localStorage.setItem(userThemeKey, dark ? "dark" : "light");
}, [profile?.id, profile?.theme]);
function toggleDark() {
const next = !isDark;
setIsDark(next);
document.documentElement.classList.toggle("dark", next);
const themeValue = next ? "dark" : "light";
if (profile?.id) {
localStorage.setItem(`hellth-theme:${profile.id}`, themeValue);
}
setTheme.mutate({ theme: themeValue });
}
return (
<ThemeContext.Provider value={{ isDark, toggleDark }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}

View file

@ -0,0 +1,87 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
/* Hellth Hub Farben */
--color-brand: #0F766E;
--color-brand-hover: #115E59;
--color-brand-light: #ECFDF5;
--color-neutral-dark: #333333;
--color-neutral-light: #F9F9F9;
--color-accent: #2563EB;
/* Tailwind-Overrides */
--color-background: #F9F9F9;
--color-foreground: #333333;
}
html.dark {
--color-background: #0a0a0a;
--color-foreground: #fafafa;
}
body {
background: var(--color-background);
color: var(--color-foreground);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
line-height: 1.6;
}
.bn-container[data-color-scheme="light"] {
--bn-colors-menu-background: #ffffff;
--bn-colors-menu-text: #111827;
--bn-colors-menu-border: #e5e7eb;
--bn-colors-hovered-background: #f3f4f6;
}
.bn-menu-dropdown {
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 14px 30px -16px rgba(17, 24, 39, 0.35);
max-height: min(60vh, 560px);
padding: 6px;
}
.bn-menu-label {
color: #6b7280;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.bn-menu-item {
border-radius: 8px;
min-height: 38px;
padding: 8px 10px;
}
.bn-mantine .bn-menu-dropdown,
.bn-mantine .mantine-Menu-dropdown {
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 14px 30px -16px rgba(17, 24, 39, 0.35);
max-height: min(60vh, 560px);
padding: 6px;
}
.bn-mantine .mantine-Menu-label {
color: #6b7280;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.bn-mantine .mantine-Menu-item {
border-radius: 8px;
min-height: 38px;
padding: 8px 10px;
}
.bn-mantine .bn-editor {
background: #ffffff;
}

22
apps/admin/app/layout.tsx Normal file
View file

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import "@mantine/core/styles.css";
import "@blocknote/mantine/style.css";
import "./globals.css";
import { Providers } from "./_components/providers";
export const metadata: Metadata = {
title: "Hellth Hub",
description: "Health- und Ernährungstracking für dein Home-Lab",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de" suppressHydrationWarning>
<body className="antialiased">
<Providers>{children}</Providers>
</body>
</html>
);
}

View file

@ -0,0 +1,188 @@
"use client";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
const OTP_STEP_STORAGE_KEY = "hellthhub_login_step_otp";
function clearOtpStep() {
try {
window.localStorage.removeItem(OTP_STEP_STORAGE_KEY);
} catch {
// ignore storage access issues
}
}
export default function OtpLoginPage() {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [method, setMethod] = useState<"totp" | "backup">("totp");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const code = inputRef.current?.value?.trim() ?? "";
const normalizedCode = method === "totp" ? code.replace(/\D/g, "") : code;
if (method === "totp" && normalizedCode.length !== 6) {
setError("Bitte den 6-stelligen OTP-Code eingeben.");
return;
}
if (method === "backup" && normalizedCode.length < 6) {
setError("Bitte einen gültigen Backup-Code eingeben.");
return;
}
const verifyResult =
method === "totp"
? await authClient.twoFactor.verifyTotp({ code: normalizedCode })
: await authClient.twoFactor.verifyBackupCode({ code: normalizedCode });
if (verifyResult.error) {
const message =
verifyResult.error.message ??
(method === "totp"
? "OTP-Code ungültig. Bitte erneut prüfen."
: "Backup-Code ungültig. Bitte erneut prüfen.");
if (
message.includes("session") ||
message.includes("Session") ||
message.includes("expired")
) {
setError(
"OTP-Sitzung abgelaufen. Bitte erneut mit E-Mail und Passwort anmelden.",
);
return;
}
setError(
method === "totp"
? "OTP-Code ungültig. Bitte erneut prüfen."
: "Backup-Code ungültig. Bitte erneut prüfen.",
);
return;
}
clearOtpStep();
if (inputRef.current) {
inputRef.current.value = "";
}
router.push("/");
} catch {
setError("2FA-Prüfung derzeit nicht möglich. Bitte erneut versuchen.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-[#F9F9F9]">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-[#0F766E] text-lg font-bold text-white shadow-sm">
HH
</div>
<h1 className="text-xl font-bold text-[#333333]">Hellth Hub</h1>
<p className="text-sm text-gray-500 mt-2">OTP-Verifizierung</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-4"
>
<div className="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1">
<button
type="button"
onClick={() => {
setMethod("totp");
setError(null);
if (inputRef.current) inputRef.current.value = "";
}}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
method === "totp" ? "bg-white text-gray-900 shadow-sm" : "text-gray-600"
}`}
>
Authenticator-Code
</button>
<button
type="button"
onClick={() => {
setMethod("backup");
setError(null);
if (inputRef.current) inputRef.current.value = "";
}}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
method === "backup" ? "bg-white text-gray-900 shadow-sm" : "text-gray-600"
}`}
>
Backup-Code
</button>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-red-600 bg-[#ECFDF5] border border-red-100 px-3 py-2.5 rounded-lg">
<span className="w-4 h-4 text-[#0F766E] shrink-0">!</span>
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-[#333333] mb-1.5">
{method === "totp" ? "OTP-Code (6-stellig)" : "Backup-Code"}
</label>
<input
ref={inputRef}
type="text"
required
autoFocus
suppressHydrationWarning
name={method === "totp" ? "one-time-code" : "backup-code"}
autoComplete={method === "totp" ? "one-time-code" : "off"}
inputMode={method === "totp" ? "numeric" : "text"}
pattern={method === "totp" ? "[0-9]{6}" : undefined}
maxLength={method === "totp" ? 6 : 64}
onInput={(e) => {
const input = e.currentTarget;
if (method === "totp") {
input.value = input.value.replace(/\D/g, "").slice(0, 6);
}
}}
placeholder={method === "totp" ? "123456" : "z. B. ABCD-EFGH"}
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-[#333333] placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-[#0F766E] focus:border-transparent transition-shadow"
/>
{method === "backup" && (
<p className="mt-1 text-xs text-gray-500">
Jeder Backup-Code ist nur einmal gültig.
</p>
)}
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-[#0F766E] text-white text-sm font-semibold rounded-lg hover:bg-[#115E59] transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-2"
>
{loading ? "Prüfen..." : method === "totp" ? "OTP prüfen" : "Backup-Code prüfen"}
</button>
<button
type="button"
onClick={() => {
clearOtpStep();
router.replace("/login");
}}
className="w-full py-2.5 bg-white text-[#333333] text-sm font-semibold rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
>
Zurück zu Login
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,272 @@
"use client";
import { authClient, resolveApiBaseUrl, signIn } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
const OTP_STEP_STORAGE_KEY = "hellthhub_login_step_otp";
const OTP_STEP_TTL_MS = 5 * 60 * 1000;
function hasActiveOtpStep(): boolean {
try {
const raw = window.localStorage.getItem(OTP_STEP_STORAGE_KEY);
if (!raw) return false;
const parsed = JSON.parse(raw) as { expiresAt?: number };
if (!parsed?.expiresAt || Date.now() > parsed.expiresAt) {
window.localStorage.removeItem(OTP_STEP_STORAGE_KEY);
return false;
}
return true;
} catch {
return false;
}
}
function setOtpStepActive() {
try {
window.localStorage.setItem(
OTP_STEP_STORAGE_KEY,
JSON.stringify({ expiresAt: Date.now() + OTP_STEP_TTL_MS }),
);
} catch {
// ignore storage access issues
}
}
function clearOtpStep() {
try {
window.localStorage.removeItem(OTP_STEP_STORAGE_KEY);
} catch {
// ignore storage access issues
}
}
async function assertAuthApiReachable() {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(`${resolveApiBaseUrl().replace(/\/$/, "")}/api/health`, {
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
} finally {
window.clearTimeout(timeout);
}
}
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [otpStepChecked, setOtpStepChecked] = useState(false);
const [passkeyAutofillTried, setPasskeyAutofillTried] = useState(false);
useEffect(() => {
if (hasActiveOtpStep()) {
router.replace("/login/otp");
return;
}
setOtpStepChecked(true);
}, [router]);
useEffect(() => {
if (!otpStepChecked || passkeyAutofillTried) return;
async function tryPasskeyAutofill() {
setPasskeyAutofillTried(true);
if (typeof window === "undefined" || !("PublicKeyCredential" in window)) {
return;
}
const pk = window.PublicKeyCredential as typeof PublicKeyCredential & {
isConditionalMediationAvailable?: () => Promise<boolean>;
};
if (typeof pk.isConditionalMediationAvailable !== "function") {
return;
}
try {
const available = await pk.isConditionalMediationAvailable();
if (!available) return;
const result = await authClient.signIn.passkey({ autoFill: true });
const hasTwoFactorChallenge =
Boolean(result.data && "twoFactorRedirect" in result.data) ||
Boolean((result as { twoFactorRedirect?: boolean }).twoFactorRedirect);
if (result.error) return;
if (hasTwoFactorChallenge) {
setOtpStepActive();
router.push("/login/otp");
return;
}
clearOtpStep();
router.push("/");
} catch {
// Best-effort only; manual login remains available.
}
}
void tryPasskeyAutofill();
}, [otpStepChecked, passkeyAutofillTried, router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
await assertAuthApiReachable();
const result = await signIn.email({ email, password });
const hasTwoFactorChallenge =
Boolean(result.data && "twoFactorRedirect" in result.data) ||
Boolean((result as { twoFactorRedirect?: boolean }).twoFactorRedirect);
if (result.error) {
setError("E-Mail oder Passwort ungültig.");
return;
}
if (hasTwoFactorChallenge) {
setOtpStepActive();
router.push("/login/otp");
return;
}
clearOtpStep();
router.push("/");
} catch {
setError(
"Login derzeit nicht möglich. Bitte prüfe, ob API/Auth auf Port 3002 läuft.",
);
} finally {
setLoading(false);
}
}
async function handlePasskeyLogin() {
setError(null);
setLoading(true);
try {
const result = await authClient.signIn.passkey();
const hasTwoFactorChallenge =
Boolean(result.data && "twoFactorRedirect" in result.data) ||
Boolean((result as { twoFactorRedirect?: boolean }).twoFactorRedirect);
if (result.error) {
const message = (result.error.message ?? "").toLowerCase();
if (message.includes("cancel")) {
setError("Passkey-Vorgang abgebrochen.");
return;
}
if (
window.location.hostname !== "localhost" &&
(message.includes("origin") || message.includes("rp") || message.includes("domain"))
) {
setError(
"Passkey auf lokaler Entwicklung bitte unter http://localhost:3001 nutzen (nicht 127.0.0.1 oder ::1).",
);
return;
}
setError(result.error.message ?? "Passkey-Anmeldung fehlgeschlagen. Bitte erneut versuchen.");
return;
}
if (hasTwoFactorChallenge) {
setOtpStepActive();
router.push("/login/otp");
return;
}
clearOtpStep();
router.push("/");
} catch {
setError("Passkey-Anmeldung derzeit nicht möglich.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-[#F9F9F9]">
{!otpStepChecked ? (
<div className="w-8 h-8 rounded-full border-2 border-[#0F766E] border-t-transparent animate-spin" />
) : (
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-[#0F766E] text-lg font-bold text-white shadow-sm">
HH
</div>
<h1 className="text-xl font-bold text-[#333333]">Hellth Hub</h1>
<p className="mt-2 text-sm text-gray-500">Health-Tracking für dein Home-Lab</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-4"
>
{error && (
<div className="flex items-center gap-2 text-sm text-red-600 bg-[#ECFDF5] border border-red-100 px-3 py-2.5 rounded-lg">
<span className="w-4 h-4 text-[#0F766E] shrink-0">!</span>
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-[#333333] mb-1.5">
E-Mail
</label>
<input
type="email"
required
autoComplete="username webauthn"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-[#333333] placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-[#0F766E] focus:border-transparent transition-shadow"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#333333] mb-1.5">
Passwort
</label>
<input
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="************"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-[#333333] placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-[#0F766E] focus:border-transparent transition-shadow"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-[#0F766E] text-white text-sm font-semibold rounded-lg hover:bg-[#115E59] transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-2"
>
{loading ? "Anmelden..." : "Anmelden"}
</button>
<button
type="button"
onClick={handlePasskeyLogin}
disabled={loading}
className="w-full py-2.5 bg-white text-[#333333] text-sm font-semibold rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Mit Passkey anmelden
</button>
</form>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,53 @@
import { createAuthClient } from "better-auth/react";
import { twoFactorClient } from "better-auth/client/plugins";
import { passkeyClient } from "@better-auth/passkey/client";
const OTP_STEP_STORAGE_KEY = "hellthhub_login_step_otp";
const OTP_STEP_TTL_MS = 5 * 60 * 1000;
function setOtpStepActive() {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
OTP_STEP_STORAGE_KEY,
JSON.stringify({ expiresAt: Date.now() + OTP_STEP_TTL_MS }),
);
} catch {
// ignore storage access issues
}
}
export function resolveApiBaseUrl() {
const configured = process.env.NEXT_PUBLIC_API_URL;
if (configured) return configured;
if (process.env.NODE_ENV === "production") {
throw new Error("NEXT_PUBLIC_API_URL muss in Produktion gesetzt sein");
}
if (typeof window !== "undefined") {
const host = window.location.hostname;
if (host.includes("::1")) {
return "http://127.0.0.1:3002";
}
}
return "http://localhost:3002";
}
export const authClient = createAuthClient({
baseURL: resolveApiBaseUrl(),
plugins: [
twoFactorClient({
onTwoFactorRedirect: async () => {
setOtpStepActive();
if (typeof window !== "undefined" && window.location.pathname !== "/login/otp") {
window.location.assign("/login/otp");
}
},
}),
passkeyClient(),
],
});
export const { signIn, signOut, signUp, useSession } = authClient;

View file

@ -0,0 +1,288 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CatalogFood,
Recipe,
amountToGrams,
buildShoppingItemsFromPlan,
buildHealthStatsSummary,
buildWeeklyBudgetSummary,
calcFoodForAmount,
calcRecipe,
classifyRecipe,
defaultHellthData,
describeIngredientAmount,
parseNutritionLabel,
recipeMacroDistribution,
redistributeWeeklyCalories,
startOfWeek,
unitLabel,
weekDates,
} from "./hellth-data";
const oliveOil: CatalogFood = {
id: "oil",
name: "Olivenöl",
category: "Öl & Fett",
kcalPer100g: 884,
proteinPer100g: 0,
carbsPer100g: 0,
fatPer100g: 100,
servingG: 15,
preferredUnit: "tbsp",
densityGPerMl: 0.91,
portions: [
{ id: "oil-tsp", label: "1 TL", unit: "tsp", amount: 1, gramsEquivalent: 4.6 },
{ id: "oil-tbsp", label: "1 EL", unit: "tbsp", amount: 1, gramsEquivalent: 13.7 },
],
source: "manual",
};
test("amountToGrams converts metric, liquid and portion units", () => {
assert.equal(amountToGrams(oliveOil, 10, "g"), 10);
assert.equal(amountToGrams(oliveOil, 100, "ml"), 91);
assert.equal(Math.round(amountToGrams(oliveOil, 2, "tsp") * 10) / 10, 9.1);
assert.equal(amountToGrams(oliveOil, 2, "tbsp", "oil-tbsp"), 27.4);
});
test("unitLabel and describeIngredientAmount keep user-facing German labels", () => {
assert.equal(unitLabel("piece"), "Stück");
assert.equal(unitLabel("tbsp"), "EL");
assert.equal(describeIngredientAmount({ foodId: "oil", amountG: 13.7, amount: 1, unit: "tbsp" }), "1 EL");
assert.equal(describeIngredientAmount({ foodId: "oil", amountG: 13.7 }), "14 g");
});
test("calcFoodForAmount and calcRecipe calculate calories and macros per portion", () => {
const skyr: CatalogFood = {
id: "skyr",
name: "Skyr",
category: "Milchprodukte",
kcalPer100g: 64,
proteinPer100g: 11,
carbsPer100g: 4,
fatPer100g: 0.2,
servingG: 500,
source: "manual",
};
const oats: CatalogFood = {
id: "oats",
name: "Haferflocken",
category: "Getreide",
kcalPer100g: 372,
proteinPer100g: 13.5,
carbsPer100g: 58.7,
fatPer100g: 7,
servingG: 50,
source: "manual",
};
const recipe: Recipe = {
id: "recipe",
name: "Protein-Frühstück",
portions: 2,
minutes: 5,
ingredients: [
{ foodId: "skyr", amountG: 500 },
{ foodId: "oats", amountG: 100 },
],
};
assert.deepEqual(calcFoodForAmount(skyr, 250), { kcal: 160, protein: 27.5, carbs: 10, fat: 0.5 });
assert.deepEqual(calcRecipe(recipe, [skyr, oats]), {
kcal: 346,
protein: 34.3,
carbs: 39.4,
fat: 4,
});
});
test("classifyRecipe detects vegetarian vegan and macro criteria", () => {
const oats: CatalogFood = {
id: "oats",
name: "Haferflocken",
category: "Getreide",
kcalPer100g: 372,
proteinPer100g: 13.5,
carbsPer100g: 58.7,
fatPer100g: 7,
servingG: 50,
source: "manual",
};
const tofu: CatalogFood = {
id: "tofu",
name: "Tofu natur",
category: "Protein",
kcalPer100g: 140,
proteinPer100g: 16,
carbsPer100g: 2,
fatPer100g: 8,
servingG: 200,
source: "manual",
};
const chicken: CatalogFood = {
id: "chicken",
name: "Hähnchenbrust",
category: "Fleisch",
kcalPer100g: 110,
proteinPer100g: 23,
carbsPer100g: 0,
fatPer100g: 1.5,
servingG: 180,
source: "manual",
};
const veganRecipe: Recipe = {
id: "vegan",
name: "Tofu Bowl",
portions: 1,
ingredients: [
{ foodId: "tofu", amountG: 200 },
{ foodId: "oats", amountG: 50 },
],
};
const chickenRecipe: Recipe = {
id: "chicken",
name: "Hähnchen Bowl",
portions: 1,
ingredients: [{ foodId: "chicken", amountG: 180 }],
};
const vegan = classifyRecipe(veganRecipe, [oats, tofu, chicken]);
const meat = classifyRecipe(chickenRecipe, [oats, tofu, chicken]);
assert.equal(vegan.vegetarian, true);
assert.equal(vegan.vegan, true);
assert.ok(vegan.tags.includes("vegan"));
assert.ok(vegan.criteria.some((item) => item.label === "Protein sinnvoll" && item.ok));
assert.equal(meat.vegetarian, false);
assert.equal(recipeMacroDistribution({ protein: 25, carbs: 50, fat: 10 }).carbs, 51);
});
test("parseNutritionLabel extracts German nutrition labels with comma decimals", () => {
const parsed = parseNutritionLabel(`
Durchschnittliche Nährwerte je 100 g
Energie 57 kcal
Fett 0,1 g
Kohlenhydrate 3,8 g
Eiweiß 10,0 g
`);
assert.equal(parsed.kcalPer100g, 57);
assert.equal(parsed.fatPer100g, 0.1);
assert.equal(parsed.carbsPer100g, 3.8);
assert.equal(parsed.proteinPer100g, 10);
});
test("week helpers start on Monday and build seven dates", () => {
assert.equal(startOfWeek("2026-05-31"), "2026-05-25");
assert.deepEqual(weekDates("2026-05-31"), [
"2026-05-25",
"2026-05-26",
"2026-05-27",
"2026-05-28",
"2026-05-29",
"2026-05-30",
"2026-05-31",
]);
});
test("weekly budget includes planned meals and activity calories", () => {
const data = defaultHellthData();
data.goals.calories = 2000;
data.weeklyCaloriePlans = [{ date: "2026-05-30", plannedCalories: 2800, note: "Restaurant" }];
data.activityLogs = [{ id: "run", date: "2026-05-25", name: "Laufen", minutes: 60, points: 100, calories: 550 }];
data.plannedMeals = [
{ id: "meal", date: "2026-05-25", mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins", portions: 1 },
];
const summary = buildWeeklyBudgetSummary(data, "2026-05-25");
assert.equal(summary.baseBudget, 14000);
assert.equal(summary.plannedBudget, 14800);
assert.equal(summary.activityCalories, 550);
assert.ok(summary.eatenCalories > 0);
assert.equal(summary.days.find((day) => day.date === "2026-05-30")?.plannedCalories, 2800);
});
test("redistributeWeeklyCalories raises one day and lowers the remaining week gently", () => {
const data = defaultHellthData();
data.goals.calories = 2000;
const plans = redistributeWeeklyCalories(data, "2026-05-30", 800, "2026-05-25");
const saturday = plans.find((plan) => plan.date === "2026-05-30");
const monday = plans.find((plan) => plan.date === "2026-05-25");
assert.equal(saturday?.plannedCalories, 2800);
assert.equal(monday?.plannedCalories, 1866);
assert.equal(saturday?.note, "bewusst höher geplant");
});
test("buildShoppingItemsFromPlan aggregates recipe ingredients and preserves manual items", () => {
const data = defaultHellthData();
data.shoppingItems = [
{ id: "manual", name: "Küchenrolle", category: "Manuell", amountG: 0, purchaseAmount: "1 Packung" },
{ id: "old-egg", foodId: "food-egg", name: "Ei", category: "Protein", amountG: 10, checked: true },
];
data.plannedMeals = [
{ id: "plan-a", date: "2026-05-26", mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins", portions: 1 },
{ id: "plan-b", date: "2026-05-27", mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins", portions: 2 },
];
const items = buildShoppingItemsFromPlan(data, ["2026-05-26"]);
const egg = items.find((item) => item.foodId === "food-egg");
assert.equal(egg?.amountG, 180);
assert.equal(egg?.checked, true);
assert.deepEqual(egg?.recipeIds, ["recipe-eggs"]);
assert.ok(items.some((item) => item.id === "manual"));
});
test("buildHealthStatsSummary creates motivating averages, streaks and weight projection", () => {
const data = defaultHellthData();
data.goals.drinkMl = 2000;
data.goals.protein = 120;
data.goals.weightKg = 100;
data.healthProfile = {
completedAt: "2026-05-01T00:00:00.000Z",
goal: "lose_weight",
age: 40,
heightCm: 180,
currentWeightKg: 110,
targetWeightKg: 100,
activityLevel: "moderate",
pace: "normal",
mealsPerDay: 3,
dietStyle: "mixed",
cookingTime: "medium",
budgetFocus: false,
excludedFoods: "",
biggestChallenge: "",
medicalNote: false,
};
data.activityLogs = [
{ id: "a1", date: "2026-05-25", name: "Spaziergang", minutes: 30, points: 60, calories: 140 },
{ id: "a2", date: "2026-05-26", name: "Laufen", minutes: 40, points: 90, calories: 380 },
];
const summary = buildHealthStatsSummary(
data,
{
days: {
"2026-05-13": { weightKg: 110 },
"2026-05-19": { weightKg: 109 },
"2026-05-26": {
weightKg: 108,
drinks: [{ amountMl: 1000 }, { amountMl: 1000 }],
foods: [{ calories: 1800, protein: 100 }],
},
},
},
"2026-05-26",
);
assert.equal(summary.averages.activityCalories > 0, true);
assert.equal(summary.streaks.waterDays, 1);
assert.equal(summary.streaks.proteinDays, 1);
assert.equal(summary.projection?.kind, "weight");
assert.equal(summary.projection?.targetDate !== undefined, true);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,107 @@
export type NewsLifecycleStatus = "draft" | "scheduled" | "published" | "archived";
export type NewsSourceFilter = "all" | "native" | "imported";
export type NewsPinnedFilter = "all" | "yes" | "no";
export type NewsListFilters = {
q: string;
statuses: NewsLifecycleStatus[];
year: "all" | string;
source: NewsSourceFilter;
pinned: NewsPinnedFilter;
showTeaser: boolean;
page: number;
};
export type NewsListItem = {
id: string;
title: string;
teaser: string | null;
source: "native" | "imported";
isPinned: boolean;
lifecycleStatus: NewsLifecycleStatus;
publishedAt: Date | string | null;
createdAt: Date | string;
author: { name?: string | null; email?: string | null } | null;
};
type SearchParamsLike = {
get: (name: string) => string | null;
};
const ALL_STATUSES: NewsLifecycleStatus[] = ["draft", "scheduled", "published", "archived"];
const DEFAULT_STATUSES: NewsLifecycleStatus[] = ["draft", "scheduled", "published"];
function sanitizeStatuses(input: string | null): NewsLifecycleStatus[] {
if (!input) return [...DEFAULT_STATUSES];
const requested = input
.split(",")
.map((value) => value.trim())
.filter((value): value is NewsLifecycleStatus =>
ALL_STATUSES.includes(value as NewsLifecycleStatus),
);
if (requested.length === 0) return [...DEFAULT_STATUSES];
return Array.from(new Set(requested));
}
export function parseNewsListFilters(params: SearchParamsLike): NewsListFilters {
const rawPage = Number(params.get("page") ?? "1");
const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1;
const rawYear = params.get("year");
const year = rawYear && rawYear !== "all" ? rawYear : "all";
const rawSource = params.get("source");
const source: NewsSourceFilter =
rawSource === "native" || rawSource === "imported" ? rawSource : "all";
const rawPinned = params.get("pinned");
const pinned: NewsPinnedFilter = rawPinned === "yes" || rawPinned === "no" ? rawPinned : "all";
return {
q: (params.get("q") ?? "").trim(),
statuses: sanitizeStatuses(params.get("statuses")),
year,
source,
pinned,
showTeaser: params.get("showTeaser") !== "0",
page,
};
}
export function buildNewsListSearchParams(filters: NewsListFilters): URLSearchParams {
const params = new URLSearchParams();
if (filters.q) params.set("q", filters.q);
if (filters.statuses.join(",") !== DEFAULT_STATUSES.join(",")) {
params.set("statuses", filters.statuses.join(","));
}
if (filters.year !== "all") params.set("year", filters.year);
if (filters.source !== "all") params.set("source", filters.source);
if (filters.pinned !== "all") params.set("pinned", filters.pinned);
if (!filters.showTeaser) params.set("showTeaser", "0");
if (filters.page > 1) params.set("page", String(filters.page));
return params;
}
export function applyNewsListFilters(items: NewsListItem[], filters: NewsListFilters) {
const q = filters.q.toLowerCase();
return items.filter((item) => {
if (!filters.statuses.includes(item.lifecycleStatus)) return false;
if (filters.source !== "all" && item.source !== filters.source) return false;
if (filters.pinned === "yes" && !item.isPinned) return false;
if (filters.pinned === "no" && item.isPinned) return false;
if (filters.year !== "all") {
const baseDate = item.publishedAt ?? item.createdAt;
const year = String(new Date(baseDate).getFullYear());
if (year !== filters.year) return false;
}
if (!q) return true;
const haystack = `${item.title} ${item.teaser ?? ""} ${item.author?.name ?? ""} ${item.author?.email ?? ""}`
.toLowerCase()
.trim();
return haystack.includes(q);
});
}

View file

@ -0,0 +1,82 @@
export type LifecycleStatus = "draft" | "scheduled" | "published" | "archived";
export function slugify(text: string): string {
return text
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 100)
.replace(/^-|-$/g, "");
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
export function extractH1Title(html: string): string {
const match = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
if (!match?.[1]) return "";
return stripHtml(match[1]).slice(0, 160).trim();
}
export function toDateValue(date?: Date | string | null) {
if (!date) return "";
return new Date(date).toISOString().slice(0, 10);
}
export function toTimeValue(date?: Date | string | null) {
if (!date) return "";
return new Date(date).toISOString().slice(11, 16);
}
export function combineDateTime(date: string, time: string) {
if (!date || !time) return undefined;
return new Date(`${date}T${time}:00`);
}
export function isSchedulingVisible(status: LifecycleStatus) {
return status === "draft" || status === "scheduled";
}
export function validatePublishInputs(
status: LifecycleStatus,
publishDate: string,
publishTime: string,
) {
if (!isSchedulingVisible(status)) return null;
if (status === "scheduled" && (!publishDate || !publishTime)) {
return "Für den Status Geplant sind Datum und Uhrzeit erforderlich.";
}
if ((publishDate && !publishTime) || (!publishDate && publishTime)) {
return "Bitte Datum und Uhrzeit gemeinsam setzen.";
}
return null;
}
export function resolveSubmitStatus(
status: LifecycleStatus,
publishDate: string,
publishTime: string,
) {
if (status === "draft" && publishDate && publishTime) return "scheduled" as const;
return status;
}
export function statusLabel(status: LifecycleStatus) {
if (status === "archived") return "Archiviert";
if (status === "published") return "Veröffentlicht";
if (status === "scheduled") return "Geplant";
return "Entwurf";
}
export function statusClass(status: LifecycleStatus) {
if (status === "archived") return "bg-gray-200 text-gray-700 dark:bg-neutral-700 dark:text-neutral-300";
if (status === "published") return "bg-green-50 text-green-700 dark:bg-green-950/50 dark:text-green-400";
if (status === "scheduled") return "bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-400";
return "bg-gray-100 text-gray-500 dark:bg-neutral-700 dark:text-neutral-400";
}

4
apps/admin/lib/trpc.ts Normal file
View file

@ -0,0 +1,4 @@
import type { AppRouter } from "@hellth/api/client";
import { createTRPCReact } from "@trpc/react-query";
export const trpc = createTRPCReact<AppRouter>();

6
apps/admin/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

24
apps/admin/next.config.ts Normal file
View file

@ -0,0 +1,24 @@
import type { NextConfig } from "next";
const securityHeaders = [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(self)" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Resource-Policy", value: "same-site" },
{ key: "Content-Security-Policy", value: "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' http: https:;" },
];
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: securityHeaders,
},
];
},
};
export default nextConfig;

54
apps/admin/package.json Normal file
View file

@ -0,0 +1,54 @@
{
"name": "admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"test": "node --import tsx --test lib/**/*.test.ts",
"lint": "eslint"
},
"dependencies": {
"@better-auth/passkey": "^1.4.18",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
"@heroicons/react": "^2.2.0",
"@mantine/core": "^8.3.15",
"@mantine/hooks": "^8.3.15",
"@mantine/utils": "^6.0.22",
"@hellth/api": "workspace:*",
"@tanstack/react-query": "^5.72.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-table": "^3.20.0",
"@tiptap/extension-table-cell": "^3.20.0",
"@tiptap/extension-table-header": "^3.20.0",
"@tiptap/extension-table-row": "^3.20.0",
"@tiptap/extension-text-align": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@trpc/client": "^11.1.0",
"@trpc/react-query": "^11.1.0",
"@trpc/server": "^11.1.0",
"better-auth": "^1.2.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"superjson": "^2.2.0",
"tesseract.js": "^7.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"tsx": "^4.19.0",
"typescript": "^5"
}
}

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View file

@ -0,0 +1,12 @@
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ("focus" in client) return client.focus();
}
if (self.clients.openWindow) return self.clients.openWindow("/");
return undefined;
}),
);
});

29
apps/admin/tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}

370
claude-info.md Normal file
View file

@ -0,0 +1,370 @@
# Hellth Hub Projektinfo
## Kurzüberblick
Hellth Hub ist eine mobile-optimierte Web-App für persönliches Health-, Ernährungs-, Aktivitäts- und Einkaufs-Tracking im Home-Lab-Betrieb.
Das Projekt wurde technisch aus einem kopierten Naturfreunde-Hub-Grundgerüst entwickelt. Naturfreunde-Funktionen wurden weitgehend entfernt oder ersetzt. Das alte Projekt dient nur noch als historische/technische Vorlage, nicht als fachliche Grundlage.
Wichtig: In deutscher UI und Dokumentation immer echte Umlaute verwenden, also `ä`, `ö`, `ü`, `ß` statt `ae`, `oe`, `ue`, `ss`.
## Arbeitsverzeichnis
Aktueller Projektpfad:
```text
c:\EurOwiG\Entwicklung\forgejo\hellth-hub
```
Das Monorepo liegt direkt im Repo-Root (`apps/`, `packages/`, `docker/` …). Der frühere `nf-hub/`-Unterordner wurde am 2026-05-27 entfernt und sein Inhalt nach oben gezogen.
## Tech Stack
- Monorepo mit `pnpm`
- Frontend/Admin: Next.js 16, React 19, Tailwind
- API: Hono, tRPC, Better Auth
- DB: PostgreSQL, Drizzle ORM
- Docker: Postgres und pgAdmin, plus vorhandener App-Container
- Auth: Better Auth mit Login, 2FA, Passkeys
## Wichtige Ports
- Admin-App lokal: `http://localhost:3001`
- API lokal: `http://localhost:3002`
- API Healthcheck: `http://localhost:3002/api/health`
- Docker-App: `http://localhost:3000`
- Postgres: `localhost:5432`
- pgAdmin: `http://localhost:5050`
Hinweis: `http://localhost:3002` direkt kann `404` liefern. Das ist normal, weil die API unter `/api/...` hängt.
## Lokaler Start
Docker/Postgres muss laufen:
```powershell
docker ps
```
Migrationen anwenden:
```powershell
pnpm --filter @hellth/db db:migrate
```
API starten:
```powershell
pnpm --filter @hellth/api dev
```
Admin starten:
```powershell
pnpm --filter admin dev
```
Seeds:
```powershell
pnpm --filter @hellth/api seed:health
pnpm --filter @hellth/api seed:demo-recipes
```
Der Demo-Recipe-Seed erzeugt 50 öffentliche Testrezepte mit IDs `demo-recipe-01` bis `demo-recipe-50`.
## Relevante Dateien und Bereiche
### Frontend
- `apps/admin/app/(authed)/page.tsx`
Tagesansicht/Dashboard.
- `apps/admin/app/(authed)/lebensmittel/page.tsx`
Lebensmittelkatalog, Suche, OCR/Barcode/Open Food Facts, Food-Votes.
- `apps/admin/app/(authed)/rezepte/page.tsx`
Rezepte, private Rezepte, Rezeptvorschläge, Nährwert-Liveberechnung, persönliche Vorschläge.
- `apps/admin/app/(authed)/rezepte/pruefung/page.tsx`
Admin-Prüfung von Rezeptvorschlägen.
- `apps/admin/app/(authed)/planung/page.tsx`
Essensplanung.
- `apps/admin/app/(authed)/einkaufsliste/page.tsx`
Einkaufslisten.
- `apps/admin/app/(authed)/maerkte/page.tsx`
Märkte, Standort-Pins, Zonen/Laufwege, Änderungsanträge.
- `apps/admin/app/(authed)/aktivitaetsfeed/page.tsx`
Admin Activity Feed für Rezeptvorschläge und Marktänderungen.
- `apps/admin/app/(authed)/hellfireclub/page.tsx`
Community-/Aktivitätsbereich mit Punkten und gemeinsamer Aktivitätsplanung.
- `apps/admin/app/(authed)/statistiken/page.tsx`
Statistiken, motivierende Auswertungen und Prognosen.
- `apps/admin/app/(authed)/gewicht/page.tsx`
Gewichtshistorie.
- `apps/admin/app/(authed)/wearables/page.tsx`
Wearables/Fitbit/Health-Connect-Vorbereitung.
- `apps/admin/app/(authed)/einstellungen/page.tsx`
User-Profil, Sicherheit, 2FA, Passkeys, Rezept-/Ernährungspräferenzen.
- `apps/admin/app/(authed)/einstellungen/punkte/page.tsx`
Globale Admin-Einstellung für Hellfireclub-/Aktivitätspunkte.
- `apps/admin/app/_components/form-field.tsx`
Globales Formularfeld-Pattern für sichtbare Labels und konsistente Inputs. Neue Formulare sollen möglichst diese Komponente nutzen.
- `apps/admin/lib/hellth-data.ts`
Frontend-nahe Health-Hilfslogik: Nährwertberechnung, Rezeptklassifizierung, Wochenbudget, Einkaufslisten, Statistiken.
### API
- `packages/api/src/trpc/routers/health.ts`
Zentrale Health API. Enthält Foods, Recipes, Recipe Submissions, Stores, Store Change Requests, Activity Feed, Planning, Shopping, Weight, Stats, Hellfireclub, Points.
- `packages/api/src/trpc/routers/users.ts`
Benutzerverwaltung und Profil.
- `packages/api/src/trpc/routers/security.ts`
2FA/Passkey/Sicherheitsstatus.
- `packages/api/src/lib/auth.ts`
Better Auth Setup.
- `packages/api/src/lib/health-coach.ts`
Regelbasierter Ernährungs-/Health-Coach.
- `packages/api/src/scripts/seed-health.ts`
Basisdaten für Lebensmittel, Märkte, Aktivitäten, Coach-Prinzipien.
- `packages/api/src/scripts/seed-demo-recipes.ts`
50 Demo-Rezepte zum manuellen Testen.
### DB
- `packages/db/src/schema/health.ts`
Zentrales Health-Schema.
- `packages/db/src/schema/auth.ts`
Better-Auth-Tabellen.
- `packages/db/drizzle/*.sql`
Migrationen.
Aktuell relevante neuere Migrationen:
- `0015_recipe_submissions.sql`
- `0016_recipe_preferences.sql`
- `0017_store_change_requests.sql`
## Aktuelle Kernfunktionen
### Dashboard / Daily View
- Tagesansicht für Mahlzeiten, Getränke, Gewicht und Tagesziele.
- Zurückliegende Tage können ergänzt werden.
- Zukunftstage sind für Essenplanung relevant.
### Lebensmittel
- Eigener Lebensmittelkatalog.
- Nährwerte pro 100 g.
- Portionsgrößen und Alltagseinheiten.
- Open Food Facts Import.
- Barcode-/Label-OCR-Vorbereitung.
- Food-Votes:
- `Mag ich`
- `Nicht meins`
- Food-Votes fließen in Rezeptvorschläge ein.
### Rezepte
- Private Rezepte möglich.
- Rezeptvorschläge können zur Community/Admin-Prüfung eingereicht werden.
- Akzeptierte Vorschläge werden öffentliche Rezepte.
- Rezeptvorschläge bringen Hellfireclub-Punkte.
- Live-Berechnung von kcal, Kohlenhydraten, Fett, Eiweiß.
- Makroverteilung wird angezeigt.
- Autokategorisierung:
- kein Fleisch/Fisch → vegetarisch
- keine tierischen Produkte → vegan
- weitere Tags wie proteinreich, kalorienbewusst usw.
### Rezeptvorschläge
Vorschläge berücksichtigen vorrangig User-Profil:
- vegan/vegetarisch/Fleisch/Fisch Mehrfachauswahl
- Mealprep 2 oder 3 Tage
- 1x oder 2x kochen pro Tag
- Fleisch/Fisch nie, maximal 1x am Tag oder flexibel
- Food-Likes und Food-Dislikes
- Kalorien, Protein und weitere Nährwerte
### Märkte und Einkauf
- User können Lebensmittelläden speichern.
- Adresse, Koordinaten und Google-Maps-Suche/Pin-Link.
- User kann Zonen/Abteilungen selbst sortieren.
- Eigene Märkte zählen zunächst als User-Daten.
- User kann Änderungen einreichen.
- Admin kann Änderungen übernehmen; dadurch werden sie globale Vorlage.
- Einkaufslisten nutzen Märkte und Abteilungen für bessere Laufweg-Sortierung.
### Admin Activity Feed
Pfad:
```text
/aktivitaetsfeed
```
Zeigt aktuell:
- Rezeptvorschläge
- Marktänderungsanträge
Marktänderungen können direkt übernommen oder abgelehnt werden.
### Hellfireclub
- Aktivitätspunkte.
- Gemeinsame, getrennte Aktivitäten.
- Standort-/Bundesland-basierte Sichtbarkeit.
- Highscore.
### Wochenbudget
- Tägliches Kalorienziel.
- Wochenbudget.
- Sportbonus.
- flexible Planung, nicht strafend.
### Wearables
- Fitbit/Google Fit/Health Connect sind vorbereitet.
- Fitbit-Aktivitätskatalog ist vorhanden.
- Manuelle Aktivitäten sind möglich.
## Auth/Admin
Admin-User:
```text
admin@onl1.eu
```
Passwort steht nicht hier dokumentieren. Falls nötig über vorhandene Admin-/Seed-Skripte neu setzen.
Admin erzwingt 2FA. Es gibt zusätzliche Logs/Sicherheitsmechanik, falls 2FA-Status verloren geht.
## Tests und Checks
Admin-Tests:
```powershell
pnpm --filter admin test
```
API-Tests:
```powershell
pnpm --filter @hellth/api test
```
API-Typecheck:
```powershell
pnpm exec tsc --noEmit --project packages/api/tsconfig.json
```
Admin-Build:
```powershell
$env:NEXT_PUBLIC_API_URL='http://localhost:3002'; pnpm --filter admin build
```
Zuletzt erfolgreich:
- API-Tests: 28/28
- Admin-Tests: 10/10
- API-Typecheck: grün
- Admin-Build: grün
## Lokale Dev-Logs
Wenn die Prozesse im Hintergrund gestartet wurden, liegen Logs hier:
```text
.logs/api-dev.out.log
.logs/api-dev.err.log
.logs/admin-dev.out.log
.logs/admin-dev.err.log
```
## Wichtige Arbeitsregeln
- Bestehende User-Änderungen nicht zurücksetzen.
- Keine destruktiven Git-Befehle ohne ausdrückliche Anweisung.
- Für manuelle Code-Edits bevorzugt `apply_patch` verwenden.
- Bei Suche `rg` bevorzugen.
- UI mobile-first denken.
- Keine Naturfreunde-Fachlogik wieder einführen.
- Neue Formulare nach Möglichkeit mit `FormField` und `inputClassName` aus `apps/admin/app/_components/form-field.tsx` bauen.
- Bei neuen DB-Funktionen Schema, Migration, API und UI zusammen denken.
- Bei deutscher UI immer Umlaute korrekt schreiben.
## Aktuelle bekannte Besonderheiten
- `localhost:3002` direkt liefert 404; `/api/health` ist der richtige API-Test.
- Docker stellt primär DB/pgAdmin bereit; lokale Entwicklung läuft über `pnpm --filter @hellth/api dev` und `pnpm --filter admin dev`.
- Die Docker-App läuft zusätzlich auf `localhost:3000`, ist aber nicht der primäre lokale Dev-Workflow.
## Wichtige Orientierung für Claude: Repo-Konsistenz
Das Hellth-Hub-Monorepo liegt direkt im Repo-Root. Aktuell relevant sind:
- `apps/admin`
- `packages/api`
- `packages/db`
- `docker/docker-compose.yml`
- Health-spezifische Dateien unter `packages/api/src/trpc/routers/health.ts`
- Health-Schema unter `packages/db/src/schema/health.ts`
- Health-UI unter `apps/admin/app/(authed)/...`
- UI-Referenzscreenshots unter `docs/inspiration/bitepal/` (kein Projektname, nur Inspiration)
### Bekannte Altlasten
Aufräum-Historie 2026-05-27:
- Phase-1/2-Demo im Root entfernt
- Nested `nf-hub/`-Ordner + separater Git-Verlauf (Remote `github.com/b4tzd/nf-hub`) entfernt
- `Screenshots Bitepal/` → `docs/inspiration/bitepal/` verschoben
- `.claude/rules.md` komplett neu geschrieben (vorher Naturfreunde-Portal-Regeln)
- `.claude/settings.json` Paketname `@naturfreunde/db` → `@hellth/db`
- `turbo.json` Tenant-/Member-Card-/Google-Wallet-Env-Vars entfernt
- `.gitignore` aufgeräumt, Live-Logs gelöscht
- `scripts/export-alte-website.py` gelöscht
Diese Altlast ist noch da und sollte **nicht angepackt werden**, weil sie die DB-State-Konsistenz brechen würde:
- `packages/db/drizzle/*.sql` und `meta/*.json` (Migrationen 0000-0008) enthalten noch Naturfreunde-Tabellen-DDL aus der Schema-Historie. Drizzle tracked sie per Datei + Hash - Umbenennen oder Löschen würde die DB inkonsistent machen. Wenn das stört, ist ein dedizierter "Schema-Reset" notwendig (alle Migrationen löschen, neue `0000_init.sql` aus aktuellem Schema generieren, DB neu aufbauen).
### Arbeitsregel für neue Aufgaben
Pfade in Antworten immer ohne `nf-hub/`-Präfix - das Monorepo liegt im Repo-Root:
```text
apps/admin
packages/api
packages/db
```
Keine Naturfreunde-Funktionalität wiederbeleben. Wenn eine Altlast aus der obigen Liste auftaucht: höchstens kurz erwähnen, dann im aktuellen Hellth-Hub-Code weiterarbeiten. Cleanup nur nach ausdrücklicher Benutzerfreigabe.

40
docker/docker-compose.yml Normal file
View file

@ -0,0 +1,40 @@
name: hellth-hub
services:
db:
image: postgres:16-alpine
container_name: hellth-hub-db
restart: unless-stopped
environment:
POSTGRES_DB: hellth_hub
POSTGRES_USER: hellth
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-hellth_dev}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hellth -d hellth_hub"]
interval: 10s
timeout: 5s
retries: 5
pgadmin:
image: dpage/pgadmin4:latest
container_name: hellth-hub-pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: info@herzeigen.eu
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
volumes:
- ./pgadmin/servers.json:/pgadmin4/servers.json:ro
- pgadmin_data:/var/lib/pgadmin
ports:
- "5050:80"
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
pgadmin_data:

View file

@ -0,0 +1,14 @@
{
"Servers": {
"1": {
"Name": "Hellth Hub Dev",
"Group": "Servers",
"Host": "db",
"Port": 5432,
"MaintenanceDB": "hellth_hub",
"Username": "hellth",
"SSLMode": "prefer",
"PassFile": "/pgpassfile"
}
}
}

13
docs/INDEX.md Normal file
View file

@ -0,0 +1,13 @@
# Hellth Hub Dokumentation
## Einstieg
- `../README.md` - Setup, Module und Verifikation
- `./projekt.md` - Produkt- und Architekturüberblick
- `./security.md` - Sicherheitsleitlinien
- `./design-guide.md` - UI- und Designgrundsätze
- `./ai/quick-reference.md` - Kurzreferenz für Agents
## Leitplanken
Hellth Hub ist keine Community-Portal-Software mehr. Alte Altprojekt-, Tenant- und öffentliche Website-Funktionen sind entfernt oder deaktiviert. Neue Arbeit fokussiert Health-Tracking, Ernährung, Planung, Ziele, Aktivitäten, Erinnerungen, Einkaufslisten und Home-Lab-Betrieb.

3
docs/ai/INDEX.md Normal file
View file

@ -0,0 +1,3 @@
# AI-Dokumentation
- `quick-reference.md` enthält die Kurzregeln für Arbeit an Hellth Hub.

View file

@ -0,0 +1,20 @@
# Quick Reference - Hellth Hub
## Produkt
Hellth Hub ist eine Health- und Ernährungstracking-App für Home-Lab-Betrieb. Fokus: Alltagstauglichkeit, Abnehmen, gesünder leben, Planung, Motivation und persönliche Ziele.
## Technik
- Frontend: `apps/admin`
- API: `packages/api`
- Datenbank: `packages/db`
- Docker: `docker/docker-compose.yml`
## Regeln
- Umlaute korrekt schreiben.
- Keine alten Altprojekt-/Tenant-/Community-Portal-Reste einführen.
- Einstellungen und Health-Daten pro Benutzer speichern.
- Auth, 2FA und Admin-Rechte nicht schwächen.
- Tests für Health-Berechnungen, Auth-nahe Änderungen und Datenownership ergänzen.

20
docs/design-guide.md Normal file
View file

@ -0,0 +1,20 @@
# Design Guide - Hellth Hub
## Richtung
Hellth Hub soll mobil, ruhig, schnell erfassbar und alltagstauglich wirken. Die App hilft beim Abnehmen und gesünder leben, ohne Schuldgefühle zu erzeugen.
## UI-Grundsätze
- Mobile-first, Desktop responsive.
- Tagesansicht als primärer Einstieg.
- Große Touch-Ziele für Essen, Trinken, Aktivitäten und Einkauf.
- Klare Fortschrittsanzeigen für Tagesziele und Wochenbudget.
- Gesundheitsfeedback positiv, konkret und handlungsorientiert formulieren.
- Keine alten Altprojekt-Farben, Logos oder Portal-Begriffe verwenden.
## Sprache
- Umlaute immer korrekt schreiben.
- Kurze, motivierende Texte.
- Keine strafenden Labels wie „versagt“, „schlecht“ oder „zu viel gegessen“.

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

29
docs/projekt.md Normal file
View file

@ -0,0 +1,29 @@
# Hellth Hub - Projektbeschreibung
Hellth Hub ist eine mobile-first Web-App für persönliches Health- und Ernährungstracking im Home-Lab. Die Anwendung nutzt das stabile Grundgerüst aus Admin-UI, Auth, 2FA, Benutzerverwaltung, API, Datenbank und Docker, ist fachlich aber vollständig auf Ernährung, Ziele, Aktivität und Alltagstauglichkeit ausgerichtet.
## Kernbereiche
- Tagesansicht: Mahlzeiten, Getränke, Gewicht, Aktivitäten, Ziele und Coach-Hinweise.
- Lebensmittel: eigener Katalog mit Nährwerten pro 100 g, Portionen, Favoriten, Barcode-/OCR-Vorbereitung.
- Rezepte: Zutaten aus Lebensmitteln, flexible Einheiten, Portionen, Bewertungen und Meal-Prep.
- Planung: Tages- und Wochenplanung mit Mahlzeiten und Zukunftslogik.
- Wochenbudget: flexible Kalorienplanung inklusive Sportbonus.
- Einkauf: automatische Einkaufslisten nach Markt, Abteilung und Laufweg.
- Ziele und Onboarding: persönliche Zielableitung pro Benutzer.
- Motivation: Erinnerungen, Aktivitätspunkte und Hellfireclub-Aktivitäten.
- Wearables: vorbereitete Struktur für Fitbit/Google-Fit-Importe.
## Architektur
- `apps/admin`: Next.js Admin- und App-Frontend.
- `packages/api`: Hono, Better Auth und tRPC.
- `packages/db`: Drizzle-Schema und Migrationen.
- `docker`: PostgreSQL und pgAdmin für lokale/Home-Lab-Setups.
## Prinzipien
- Motivierend statt strafend.
- Benutzerbezogene Einstellungen statt globale Health-Defaults.
- Alltagseinheiten vor perfekter Laborpräzision.
- Erweiterbar für Wearables, OCR, Barcode, Preisvergleich und Offline-Einkaufsmodus.

18
docs/security.md Normal file
View file

@ -0,0 +1,18 @@
# Security - Hellth Hub
## Grundsätze
- Auth ist Pflicht für alle App- und Admin-Bereiche.
- 2FA und Passkeys bleiben zentrale Sicherheitsfunktionen.
- Benutzerverwaltung ist nur für Plattform-Admins zugänglich.
- Health-Daten werden immer benutzerbezogen gefiltert.
- Uploads werden nach Typ und Größe geprüft.
- In Produktion muss HTTPS erzwungen werden.
- Secrets gehören in `.env`, nicht in Markdown oder UI-Code.
## Prüfpunkte
- Neue tRPC-Endpunkte brauchen `protectedProcedure` oder bewusst öffentliche Routen.
- Mutationen müssen Ownership prüfen, bevor bestehende Datensätze geändert werden.
- Logs dürfen keine Tokens, Passwörter oder Wearable-Secrets enthalten.
- Wearable-Tokens müssen verschlüsselt gespeichert werden, sobald echte OAuth-Anbindung aktiv ist.

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "hellth-hub",
"private": true,
"packageManager": "pnpm@10.30.1",
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test",
"typecheck": "turbo typecheck"
},
"devDependencies": {
"turbo": "^2.8.10"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "sharp", "unrs-resolver"],
"overrides": {
"esbuild": ">=0.25.0"
}
}
}

40
packages/api/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "@hellth/api",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./client": "./src/trpc/client.ts"
},
"scripts": {
"dev": "node --watch --import tsx src/index.ts",
"test": "node --env-file ../../.env --import tsx --test src/**/*.test.ts",
"seed:admin": "node --import tsx src/scripts/seed-admin.ts",
"seed:health": "node --import tsx src/scripts/seed-health.ts",
"seed:demo-recipes": "node --import tsx src/scripts/seed-demo-recipes.ts"
},
"dependencies": {
"@better-auth/passkey": "^1.4.18",
"@hono/node-server": "^1.14.0",
"@hono/trpc-server": "^0.3.0",
"@hellth/db": "workspace:*",
"@trpc/server": "^11.1.0",
"better-auth": "^1.2.0",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.44.0",
"hono": "^4.7.0",
"qrcode": "^1.5.4",
"sanitize-html": "^2.17.0",
"superjson": "^2.2.0",
"tsx": "^4.19.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/qrcode": "^1.5.6",
"@types/sanitize-html": "^2.16.0",
"typescript": "^5.0.0"
}
}

177
packages/api/src/app.ts Normal file
View file

@ -0,0 +1,177 @@
import { trpcServer } from "@hono/trpc-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { auth } from "./lib/auth";
import { getImageExtension, getUploadsRoot, saveUploadedImage } from "./lib/uploads";
import { resolveRequestProtocol } from "./lib/runtime-security";
import { appRouter } from "./trpc";
import { createContext } from "./trpc/context";
import { readFile } from "fs/promises";
import { join, normalize, resolve } from "path";
const app = new Hono();
const isProduction = process.env.NODE_ENV === "production";
const allowedOrigins = new Set(
((process.env.TRUSTED_ORIGINS ??
(isProduction
? ""
: "http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000,http://127.0.0.1:3001")))
.split(",")
.map((origin) => origin.trim())
.filter(Boolean),
);
app.use(
"/api/*",
cors({
origin: (origin) => {
// Non-browser clients (no Origin header) do not need CORS.
if (!origin) return undefined;
// Explicit allow-list from TRUSTED_ORIGINS
if (allowedOrigins.has(origin)) return origin;
// Local development fallback for localhost/127.0.0.1 on arbitrary ports
if (!isProduction) {
try {
const hostname = new URL(origin).hostname;
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
return origin;
}
} catch {
return undefined;
}
}
return undefined;
},
credentials: true,
}),
);
app.use("*", async (c, next) => {
if (isProduction) {
const protocol = resolveRequestProtocol({
requestUrl: c.req.url,
trustProxyForwardHeaders: process.env.TRUST_PROXY_FORWARD_HEADERS === "true",
forwardedProtoHeader: c.req.header("x-forwarded-proto"),
});
if (protocol !== "https") {
return c.json({ error: "HTTPS erforderlich" }, 426);
}
}
await next();
c.header("X-Content-Type-Options", "nosniff");
c.header("X-Frame-Options", "DENY");
c.header("Referrer-Policy", "strict-origin-when-cross-origin");
c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=(self)");
c.header("Cross-Origin-Resource-Policy", "same-site");
c.header("Cross-Origin-Opener-Policy", "same-origin");
c.header("Cross-Origin-Embedder-Policy", "unsafe-none");
c.header("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'; base-uri 'self'");
if (isProduction) {
c.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
}
});
// Better Auth
app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));
// Authenticated image upload for editor blocks
app.post("/api/uploads/image", async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) {
return c.json({ error: "Nicht authentifiziert" }, 401);
}
const body = await c.req.parseBody();
const file = body.file;
if (!(file instanceof File)) {
return c.json({ error: "Datei fehlt (field: file)" }, 400);
}
if (!file.type.startsWith("image/")) {
return c.json({ error: "Nur Bilddateien sind erlaubt" }, 400);
}
// Limit editor uploads to 8 MB
if (file.size > 8 * 1024 * 1024) {
return c.json({ error: "Datei ist zu groß (max. 8 MB)" }, 400);
}
const ext = getImageExtension(file.type, file.name);
if (!ext) {
return c.json({ error: "Dateiformat nicht unterstützt" }, 400);
}
const buffer = Buffer.from(await file.arrayBuffer());
const relPath = await saveUploadedImage(buffer, ext);
const baseUrl = (process.env.PUBLIC_ASSETS_BASE_URL ?? process.env.BETTER_AUTH_BASE_URL ?? "")
.trim()
.replace(/\/$/, "");
const fallbackOrigin = new URL(c.req.url).origin;
const publicUrl = `${baseUrl || fallbackOrigin}/uploads/${relPath}`;
return c.json({ url: publicUrl });
});
// tRPC
app.use(
"/api/trpc/*",
trpcServer({
endpoint: "/api/trpc",
router: appRouter,
createContext,
}),
);
// Health Check
app.get("/api/health", (c) =>
c.json({ status: "ok", timestamp: new Date().toISOString() }),
);
// Public serving of uploaded files
app.get("/uploads/*", async (c) => {
const reqPath = c.req.path.replace(/^\/uploads\//, "");
const safeRelPath = normalize(reqPath).replace(/^(\.\.(\/|\\|$))+/, "");
const absRoot = getUploadsRoot();
const absPath = resolve(join(absRoot, safeRelPath));
if (!absPath.startsWith(absRoot)) {
return c.text("Forbidden", 403);
}
let data: ArrayBuffer;
try {
const fileBuffer = await readFile(absPath);
data = fileBuffer.buffer.slice(
fileBuffer.byteOffset,
fileBuffer.byteOffset + fileBuffer.byteLength,
);
} catch {
return c.text("Not Found", 404);
}
const lower = absPath.toLowerCase();
const contentType = lower.endsWith(".png")
? "image/png"
: lower.endsWith(".jpg") || lower.endsWith(".jpeg")
? "image/jpeg"
: lower.endsWith(".webp")
? "image/webp"
: lower.endsWith(".gif")
? "image/gif"
: lower.endsWith(".avif")
? "image/avif"
: "application/octet-stream";
return c.newResponse(data, 200, {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
});
});
export { app };
export type { AppRouter } from "./trpc";

View file

@ -0,0 +1,11 @@
export const APP_ERROR_CODES = {
AUTH_NOT_AUTHENTICATED: "AUTH_NOT_AUTHENTICATED",
AUTH_NOT_ADMIN: "AUTH_NOT_ADMIN",
AUTH_2FA_REQUIRED: "AUTH_2FA_REQUIRED",
NEWS_NOT_FOUND: "NEWS_NOT_FOUND",
NEWS_SLUG_CONFLICT: "NEWS_SLUG_CONFLICT",
EVENT_NOT_FOUND: "EVENT_NOT_FOUND",
UNKNOWN: "UNKNOWN",
} as const;
export type AppErrorCode = (typeof APP_ERROR_CODES)[keyof typeof APP_ERROR_CODES];

20
packages/api/src/index.ts Normal file
View file

@ -0,0 +1,20 @@
import { config } from "dotenv";
config({ path: "../../.env" });
try {
const { db, runMigrations } = await import("@hellth/db");
await runMigrations(db);
const { serve } = await import("@hono/node-server");
const { app } = await import("./app");
const port = Number(process.env.API_PORT) || 3002;
serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }, (info) => {
console.log(`API läuft auf http://localhost:${info.port}`);
});
} catch (err) {
console.error("API konnte nicht gestartet werden:", err);
process.exit(1);
}

View file

@ -0,0 +1,114 @@
import { db } from "@hellth/db";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { passkey } from "@better-auth/passkey";
import { admin } from "better-auth/plugins/admin";
import { twoFactor } from "better-auth/plugins/two-factor";
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET ist nicht gesetzt - bitte .env prüfen");
}
const isProduction = process.env.NODE_ENV === "production";
const defaultTrustedOrigins = isProduction
? []
: [
"http://localhost:3000",
"http://localhost:3001",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://[::1]:3000",
"http://[::1]:3001",
];
const envTrustedOrigins = (process.env.TRUSTED_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const trustedOrigins = Array.from(new Set([...defaultTrustedOrigins, ...envTrustedOrigins]));
const passkeyOrigins = trustedOrigins.filter((origin) =>
origin.startsWith("http://") || origin.startsWith("https://"),
);
const passkeyRpId = process.env.PASSKEY_RP_ID ?? "localhost";
const passkeyRpName = process.env.PASSKEY_RP_NAME ?? "Hellth Hub";
const authBaseUrl = process.env.BETTER_AUTH_BASE_URL ?? (isProduction ? "" : "http://localhost:3002");
if (!authBaseUrl) {
throw new Error("BETTER_AUTH_BASE_URL ist in Produktion zwingend erforderlich");
}
if (isProduction && !authBaseUrl.startsWith("https://")) {
throw new Error("BETTER_AUTH_BASE_URL muss in Produktion mit https:// beginnen");
}
export const auth = betterAuth({
baseURL: authBaseUrl,
secret: process.env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, {
provider: "pg",
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 16,
maxPasswordLength: 128,
// Kein Self-Service SignUp. Für Seed-Script kann es temporär per Env erlaubt werden.
disableSignUp: process.env.ALLOW_SEED_SIGNUP !== "true",
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 Tage
updateAge: 60 * 60 * 24, // Session täglich erneuern
cookieCache: {
// User-Objekt (inkl. Avatar) nicht in Cookie cachen, sonst drohen Cookie-Overflows.
enabled: false,
maxAge: 5 * 60, // 5 Minuten Cache
},
},
advanced: {
cookiePrefix: "hellthhub",
cookies: {
session_token: {
attributes: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
},
},
},
crossSubDomainCookies: {
enabled: false,
},
},
rateLimit: {
enabled: true,
window: 60,
max: 20,
customRules: {
// Login-Route extra restriktiv
"/api/auth/sign-in/email": {
window: 60,
max: 5,
},
},
},
plugins: [
admin({
adminRoles: ["admin"],
defaultRole: "user",
}),
twoFactor({
issuer: "Hellth Hub",
}),
passkey({
rpID: passkeyRpId,
rpName: passkeyRpName,
origin: passkeyOrigins,
}),
],
trustedOrigins,
});

View file

@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildDailyCoachSignals } from "./health-coach";
test("buildDailyCoachSignals prioritizes medical safety notes", () => {
const signals = buildDailyCoachSignals({
date: "2026-05-25",
calorieGoal: 2000,
proteinGoal: 150,
drinkGoalMl: 2500,
calories: 1200,
proteinG: 60,
drinkMl: 800,
activityMinutes: 0,
activityCalories: 0,
medicalNote: true,
});
assert.equal(signals[0]?.id, "medical-note");
assert.equal(signals[0]?.priority, "high");
assert.ok(signals.some((signal) => signal.id === "protein-low"));
});
test("buildDailyCoachSignals reacts to over-budget days without blame", () => {
const signals = buildDailyCoachSignals({
date: "2026-05-25",
calorieGoal: 2000,
proteinGoal: 150,
drinkGoalMl: 2500,
calories: 2450,
proteinG: 150,
drinkMl: 2500,
activityMinutes: 45,
activityCalories: 300,
});
assert.ok(signals.some((signal) => signal.id === "calories-over"));
assert.ok(signals.find((signal) => signal.id === "calories-over")?.body.includes("kein Scheitern"));
assert.ok(signals.some((signal) => signal.id === "activity-bonus"));
});
test("buildDailyCoachSignals returns steady feedback for balanced days", () => {
const signals = buildDailyCoachSignals({
date: "2026-05-25",
calorieGoal: 2000,
proteinGoal: 150,
drinkGoalMl: 2500,
calories: 1900,
proteinG: 150,
drinkMl: 2500,
activityMinutes: 30,
activityCalories: 0,
});
assert.deepEqual(signals.map((signal) => signal.id), ["steady-day"]);
});

View file

@ -0,0 +1,115 @@
export type CoachPriority = "high" | "medium" | "low";
export type CoachSignalInput = {
date: string;
calorieGoal: number;
proteinGoal: number;
drinkGoalMl: number;
calories: number;
proteinG: number;
drinkMl: number;
activityMinutes: number;
activityCalories: number;
medicalNote?: boolean;
};
export type CoachSignal = {
id: string;
title: string;
body: string;
priority: CoachPriority;
category: string;
};
export function buildDailyCoachSignals(input: CoachSignalInput): CoachSignal[] {
const signals: CoachSignal[] = [];
const proteinRatio = input.proteinGoal > 0 ? input.proteinG / input.proteinGoal : 1;
const drinkRatio = input.drinkGoalMl > 0 ? input.drinkMl / input.drinkGoalMl : 1;
const calorieRatio = input.calorieGoal > 0 ? input.calories / input.calorieGoal : 0;
if (input.medicalNote) {
signals.push({
id: "medical-note",
title: "Gesundheit zuerst",
body: "Deine Ziele sollten bei medizinischen Themen mit einer Ärztin, einem Arzt oder einer Ernährungsfachkraft abgestimmt werden. Hellth Hub unterstützt, ersetzt aber keine medizinische Beratung.",
priority: "high",
category: "safety",
});
}
if (proteinRatio < 0.65) {
signals.push({
id: "protein-low",
title: "Protein früher einplanen",
body: "Heute ist dein Protein noch niedrig. Eine klare Proteinquelle in der nächsten Mahlzeit hilft Sättigung, Muskelerhalt und Planbarkeit.",
priority: "high",
category: "nutrition",
});
} else if (proteinRatio < 0.9) {
signals.push({
id: "protein-almost",
title: "Protein fast im Ziel",
body: "Du bist beim Protein auf einem guten Weg. Eine kleine Ergänzung wie Skyr, Eier, Hülsenfrüchte oder mageres Fleisch kann den Tag abrunden.",
priority: "medium",
category: "nutrition",
});
}
if (drinkRatio < 0.5) {
signals.push({
id: "water-low",
title: "Trinken einfacher machen",
body: "Dein Trinkziel ist noch weit weg. Stell dir ein großes Glas sichtbar hin und nimm die nächsten 500 ml als kleine Etappe.",
priority: "medium",
category: "hydration",
});
}
if (calorieRatio > 1.15) {
signals.push({
id: "calories-over",
title: "Woche statt einzelner Tag",
body: "Der Tag liegt über dem Ziel. Das ist kein Scheitern: nutze das Wochenbudget und plane die nächsten Tage etwas ruhiger.",
priority: "medium",
category: "weekly-budget",
});
} else if (calorieRatio < 0.65 && input.calories > 0) {
signals.push({
id: "calories-too-low",
title: "Nicht zu hart sparen",
body: "Der Tag wirkt sehr niedrig geplant. Ein zu großes Defizit kann Hunger und Abbrüche fördern. Plane lieber eine einfache, sättigende Mahlzeit.",
priority: "medium",
category: "weekly-budget",
});
}
if (input.activityMinutes === 0) {
signals.push({
id: "movement-missing",
title: "Kleine Bewegung zählt",
body: "Fünf bis zehn Minuten gehen oder dehnen reichen als Start. Ziel ist kein perfektes Training, sondern ein besserer nächster Schritt.",
priority: "low",
category: "activity",
});
} else if (input.activityCalories > 0) {
signals.push({
id: "activity-bonus",
title: "Aktivität ist eingezahlt",
body: `Du hast dir heute etwa ${input.activityCalories} kcal Sportbonus erarbeitet. Nutze ihn bewusst, nicht automatisch.`,
priority: "low",
category: "activity",
});
}
if (signals.length === 0) {
signals.push({
id: "steady-day",
title: "Stabiler Tag",
body: "Die wichtigsten Werte sehen stimmig aus. Wiederholbare Tage sind wertvoller als perfekte Ausreißer.",
priority: "low",
category: "routine",
});
}
return signals.slice(0, 5);
}

View file

@ -0,0 +1,62 @@
import sanitizeHtml from "sanitize-html";
const ALLOWED_TAGS = [
"p",
"br",
"strong",
"em",
"u",
"s",
"blockquote",
"ul",
"ol",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"hr",
"code",
"pre",
"span",
"div",
];
const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = {
a: ["href", "name", "target", "rel"],
img: ["src", "alt", "title", "width", "height", "loading"],
"*": ["class"],
};
const ALLOWED_SCHEMES = ["http", "https", "mailto", "tel", "data"];
const ALLOWED_SCHEMES_BY_TAG: sanitizeHtml.IOptions["allowedSchemesByTag"] = {
img: ["http", "https", "data"],
};
export function sanitizeRichHtml(input: string | null | undefined) {
if (!input) return "";
return sanitizeHtml(input, {
allowedTags: ALLOWED_TAGS,
allowedAttributes: ALLOWED_ATTRIBUTES,
allowedSchemes: ALLOWED_SCHEMES,
allowedSchemesByTag: ALLOWED_SCHEMES_BY_TAG,
allowProtocolRelative: false,
disallowedTagsMode: "discard",
transformTags: {
a: sanitizeHtml.simpleTransform("a", {
rel: "noopener noreferrer nofollow",
target: "_blank",
}),
},
});
}

View file

@ -0,0 +1,31 @@
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
function normalizeHostHeader(raw: string) {
const value = raw.trim().toLowerCase();
if (!value) return "";
const first = value.split(",")[0]?.trim() ?? "";
if (!first) return "";
if (first.startsWith("[")) {
const idx = first.indexOf("]");
if (idx > 0) return first.slice(1, idx);
return "";
}
return first.replace(/:\d+$/, "").replace(/\.$/, "");
}
function trustForwardedHost() {
return process.env.TRUST_PROXY_FORWARD_HEADERS === "true";
}
export function getRequestHostname(headers: Headers) {
const forwardedHost = headers.get("x-forwarded-host");
const host = headers.get("host");
const preferred = trustForwardedHost() ? forwardedHost ?? host : host ?? forwardedHost;
return preferred ? normalizeHostHeader(preferred) : "";
}
export function getRequestHostnameFromContext(opts: FetchCreateContextFnOptions) {
return getRequestHostname(opts.req.headers);
}

View file

@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isPlatformAdminUser,
parseConfiguredAdminEmails,
resolveRequestProtocol,
} from "./runtime-security";
test("parseConfiguredAdminEmails returns empty list for undefined input", () => {
assert.deepEqual(parseConfiguredAdminEmails(undefined), []);
});
test("isPlatformAdminUser is false without explicit config and without admin role", () => {
const result = isPlatformAdminUser({
userRole: "user",
userEmail: "admin@onl1.eu",
configuredAdminEmails: [],
});
assert.equal(result, false);
});
test("isPlatformAdminUser accepts configured email allow-list", () => {
const result = isPlatformAdminUser({
userRole: "user",
userEmail: "ADMIN@example.org",
configuredAdminEmails: ["admin@example.org"],
});
assert.equal(result, true);
});
test("resolveRequestProtocol honors forwarded proto when proxy headers are trusted", () => {
const protocol = resolveRequestProtocol({
requestUrl: "http://internal-service/api/trpc",
trustProxyForwardHeaders: true,
forwardedProtoHeader: "https",
});
assert.equal(protocol, "https");
});
test("resolveRequestProtocol rejects missing https in production-like direct requests", () => {
const protocol = resolveRequestProtocol({
requestUrl: "http://localhost:3002/api/trpc",
trustProxyForwardHeaders: false,
forwardedProtoHeader: null,
});
assert.equal(protocol, "http");
});

View file

@ -0,0 +1,43 @@
export function parseConfiguredAdminEmails(raw: string | undefined) {
return (raw ?? "")
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean);
}
export function isPlatformAdminUser(input: {
userRole: string | null | undefined;
userEmail: string | null | undefined;
configuredAdminEmails: string[];
}) {
const normalizedRole = (input.userRole ?? "").toLowerCase();
if (normalizedRole === "admin" || normalizedRole === "platform_admin") {
return true;
}
const normalizedEmail = (input.userEmail ?? "").trim().toLowerCase();
if (!normalizedEmail) return false;
return input.configuredAdminEmails.includes(normalizedEmail);
}
export function resolveRequestProtocol(input: {
requestUrl: string;
trustProxyForwardHeaders: boolean;
forwardedProtoHeader: string | null | undefined;
}) {
if (input.trustProxyForwardHeaders) {
const forwarded = input.forwardedProtoHeader?.split(",")[0]?.trim().toLowerCase();
if (forwarded === "http" || forwarded === "https") {
return forwarded;
}
}
try {
const protocol = new URL(input.requestUrl).protocol.toLowerCase();
if (protocol === "https:") return "https";
} catch {
// Fall through to fail-safe default.
}
return "http";
}

View file

@ -0,0 +1,26 @@
type SecurityAuditMetadata = Record<string, unknown>;
export function auditSecurityEvent(action: string, metadata: SecurityAuditMetadata = {}) {
console.warn("[security]", {
action,
at: new Date().toISOString(),
...metadata,
});
}
export function twoFactorLossReasons(input: {
isPrivileged: boolean;
userTwoFactorEnabled?: boolean | null;
twoFactorRowExists: boolean;
twoFactorRowEnabled?: boolean | null;
recoveryRequired?: boolean | null;
}) {
if (!input.isPrivileged) return [];
const reasons: string[] = [];
if (!input.userTwoFactorEnabled) reasons.push("user.twoFactorEnabled=false");
if (!input.twoFactorRowExists) reasons.push("twoFactor row missing");
if (input.twoFactorRowExists && !input.twoFactorRowEnabled) reasons.push("twoFactor.enabled=false");
if (input.recoveryRequired) reasons.push("twoFactorRecoveryRequired=true");
return reasons;
}

Some files were not shown because too many files have changed in this diff Show more