commit 2436dd9cac2d630cb23f6a31c715baeb4900e886 Author: Sebastian Mayer Date: Wed May 27 22:15:16 2026 +0200 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) diff --git a/.claude/rules.md b/.claude/rules.md new file mode 100644 index 0000000..c1cbc27 --- /dev/null +++ b/.claude/rules.md @@ -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") diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..bd5e8df --- /dev/null +++ b/.claude/settings.json @@ -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:*)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eb4d82f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6a4c14 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..680fc53 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +onlyBuiltDependencies[]=esbuild +onlyBuiltDependencies[]=sharp +onlyBuiltDependencies[]=unrs-resolver +shamefully-hoist=true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..152527c --- /dev/null +++ b/AGENTS.md @@ -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. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dfc836e --- /dev/null +++ b/CLAUDE.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dac1da --- /dev/null +++ b/README.md @@ -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 +``` \ No newline at end of file diff --git a/apps/admin/app/(authed)/aktivitaeten/page.tsx b/apps/admin/app/(authed)/aktivitaeten/page.tsx new file mode 100644 index 0000000..c9b7b0b --- /dev/null +++ b/apps/admin/app/(authed)/aktivitaeten/page.tsx @@ -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("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 ( +
+
+
+
+

Belohnungssystem

+

Aktivitätspunkte

+

+ Fitbit-kompatibler Katalog, verbrannte kcal und manuelle Nacherfassung für Training ohne Uhr. +

+
+
+
+

{dayPoints}

+

heute

+
+
+
+
+ +
+
+
+ +
+

{weekPoints}

+

Punkte diese Woche

+
+
+
+
+
+ +
+

{weekMinutes}

+

aktive Minuten

+
+
+
+
+
+ +
+

{weekCalories}

+

kcal Sportbonus

+
+
+
+
+
+ +
+

{pointsLabel(weekPoints)}

+

aktueller Status

+
+
+
+
+ +
+
+

Woche

+ setSelectedDate(event.target.value || todayKey())} + className="rounded-full border border-gray-200 px-4 py-3 text-sm font-bold" + /> +
+
+ {weeklyDays.map((day) => { + const active = day.key === selectedDate; + return ( + + ); + })} +
+
+ +
+
+
+

Fitness-Katalog

+
+ {(["all", "fitbit", "manual"] as const).map((key) => ( + + ))} +
+
+ 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" + /> +
+ {filteredTemplates.map((template) => ( + + ))} + {!filteredTemplates.length && !templatesQuery.isLoading ? ( +

Keine passende Vorlage gefunden.

+ ) : null} +
+
+ +