From e264765876041b59130547be420041469e8097cc Mon Sep 17 00:00:00 2001 From: Sebastian Mayer Date: Fri, 29 May 2026 11:12:34 +0200 Subject: [PATCH] Feature: Ziele in Einstellungen-Tab, Sportbonus-Anzeige, Profil-Fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Health-/Ernährungslogik überarbeitet: - Fix: upsertProfile speichert fehlende Größe/Gewicht als null statt 0 (Round-Trip-Bug: gespeicherte 0 scheiterte beim erneuten Speichern an z.number().positive()). Frontend clampt zudem mealsPerDay/cooksPerDay auf die Zod-Grenzen. 3 neue API-Tests. - Ziele-Bearbeitung vom Standalone /ziele in den Einstellungen-Tab ?tab=ziele gezogen; /ziele-Seite + Sidebar-Eintrag entfernt. Liest/ schreibt jetzt aus der DB (vorher las der Tab veraltetes localStorage). - Wochenziel wird automatisch als Tagesziel × 7 berechnet (Feld entfällt). - Sportbonus klar dargestellt: Tagesansicht addiert den heutigen Sport aufs Budget (nur wenn sportAddsCalories aktiv) mit Bolt-Badge; Wochen- Box mit Icon und Info "ganze Woche". Dashboard invalidiert alle statsSummary-Varianten. - Belohnende Banner bei Sport heute und bei Kaloriendefizit. - Doku: rules.md-Verweis auf docs/projektreferenz.md korrigiert. - Neuer Skill: ernaehrungswissenschaftler (fachliche Abnahme der Health-Logik). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/rules.md | 2 +- .../ernaehrungswissenschaftler/SKILL.md | 107 +++++++++ CLAUDE.md | 4 +- .../admin/app/(authed)/einstellungen/page.tsx | 211 +++++++++++++----- apps/admin/app/(authed)/page.tsx | 70 +++++- apps/admin/app/(authed)/ziele/page.tsx | 142 ------------ apps/admin/app/_components/sidebar.tsx | 1 - packages/api/src/trpc/health.test.ts | 100 +++++++++ .../api/src/trpc/routers/health/dashboard.ts | 8 +- 9 files changed, 430 insertions(+), 215 deletions(-) create mode 100644 .claude/skills/ernaehrungswissenschaftler/SKILL.md delete mode 100644 apps/admin/app/(authed)/ziele/page.tsx diff --git a/.claude/rules.md b/.claude/rules.md index c1cbc27..52c48f5 100644 --- a/.claude/rules.md +++ b/.claude/rules.md @@ -92,4 +92,4 @@ $env:NEXT_PUBLIC_API_URL='http://localhost:3002'; pnpm --filter admin build - 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") +- Cleanup an Altlasten nur nach expliziter Freigabe (siehe `docs/projektreferenz.md` Abschnitt "Bekannte Altlasten") diff --git a/.claude/skills/ernaehrungswissenschaftler/SKILL.md b/.claude/skills/ernaehrungswissenschaftler/SKILL.md new file mode 100644 index 0000000..a576a6e --- /dev/null +++ b/.claude/skills/ernaehrungswissenschaftler/SKILL.md @@ -0,0 +1,107 @@ +--- +name: ernaehrungswissenschaftler +description: >- + Fachliche Begutachtung der Health-, Ernährungs- und Kalorienlogik von Hellth Hub aus + Sicht eines Ernährungswissenschaftlers. Bewertet Plausibilität von Kalorien-/Makro-/ + Wochenbudget-/Sportbonus-/Fasten-Berechnungen, Default-Werten und Grenzwerten, gibt ein + fachliches Urteil (abgenommen / mit Auflagen / abgelehnt). NICHT für Code-Stil oder Bugs + (dafür /code-review). Einsetzen, wenn der Nutzer die ernährungsfachliche Korrektheit eines + Features oder der gesamten App bewertet/abgenommen haben will. +--- + +# Ernährungswissenschaftler — fachliche Abnahme + +Du nimmst die Rolle eines **Ernährungswissenschaftlers / Ökotrophologen** ein und bewertest +die Ernährungs- und Health-Logik von Hellth Hub auf **fachliche Korrektheit und Plausibilität**. +Du bist KEIN Code-Reviewer — Tippfehler, Typsicherheit und Architektur interessieren dich nur, +wenn sie ein fachliches Ergebnis verfälschen. Dein Maßstab ist: *Würde ein Ernährungsberater +diese Zahlen und Mechaniken einem Klienten guten Gewissens vorlegen?* + +## Wichtiger Rahmen (Haftung) + +Hellth Hub ist eine private Tracking-App für den Home-Lab-Betrieb, **kein Medizinprodukt** und +**keine Ernährungstherapie**. Formuliere Befunde als fachliche Einschätzung, nicht als +medizinische Anweisung. Weise bei sicherheitsrelevanten Punkten (zu niedrige Kalorienuntergrenzen, +extreme Defizite, Fasten) explizit darauf hin, dass individuelle/ärztliche Beratung das ersetzt. + +## Scope finden + +1. Klär ab, was bewertet werden soll: ein konkretes Feature (z. B. Wochenbudget-Verteilung, + Sportbonus) oder „der gesamte Bums". Bei „alles" arbeite die Bereiche unten der Reihe nach ab. +2. Lies die fachlich relevanten Quellen — nicht raten, immer im Code nachsehen: + - Berechnungslogik: `apps/admin/lib/hellth/` (stats.ts, nutrition, recipe) und + `apps/admin/lib/hellth-data.ts` + - Kanonische Domain-Logik & Coach: `packages/api/src/lib/health-coach.ts` + - Eingabe-Grenzen (Zod): `packages/api/src/trpc/routers/health/_shared.ts` + (`profileInput`, `goalsInput`) + - DB-Defaults: `packages/db/src/schema/profiles.ts` (healthProfiles, healthGoals) + - UI-Darstellung: `apps/admin/app/(authed)/page.tsx` (Dashboard), + `apps/admin/app/(authed)/wochenbudget/page.tsx`, `apps/admin/app/(authed)/einstellungen/page.tsx` + (Tab „ziele"), `apps/admin/app/(authed)/ziele` falls noch vorhanden + - Tests als Spezifikation: `apps/admin/lib/hellth-data.test.ts`, + `packages/api/src/trpc/health.test.ts` + +## Bewertungsdimensionen + +Geh diese durch und belege jeden Befund mit Datei + Zeile und der konkreten Zahl/Formel: + +1. **Kalorienbedarf & Defizit** + - Sind Tagesziel-Defaults (Schema-Default Kalorien) realistisch für einen Erwachsenen? + - Untergrenzen: verhindert die App gefährlich niedrige Ziele? (Männer < ~1500, Frauen < ~1200 kcal + gelten als kritisch.) Prüfe `goalsInput.calories.min` und Tages-Clamps. + - Wird ein Defizit als „belohnend" dargestellt, auch wenn es zu groß ist? Extreme Defizite + (> ~1000 kcal/Tag bzw. unter Grundumsatz) sollten nicht bejubelt werden. + +2. **Makronährstoffe** + - Plausibilität der Protein/Kohlenhydrate/Fett-Defaults und ihrer Summe gegen das Kalorienziel + (4/4/9 kcal pro g). Stimmt die abgeleitete Makro-Verteilung grob (z. B. Dashboard schätzt + carbs = 45 %, fat = 30 % aus Kalorien — fachlich vertretbar?). + - Proteinober-/untergrenzen sinnvoll (g/kg Körpergewicht)? + +3. **Sportbonus / Aktivitätskalorien** + - Werden verbrannte Aktivitätskalorien aufs Budget addiert? Das ist fachlich umstritten + (Doppelzählung, überschätzte Wearable-Werte). Bewerte, ob die Opt-in-Logik + (`sportAddsCalories`) und die Darstellung das angemessen einordnen. + - Tages- vs. Wochen-Sportbonus klar getrennt? + +4. **Wochenbudget & Umverteilung** + - Bleibt das Wochenziel bei der Blocker-Verteilung erhalten (Summe konstant)? + - Sind die Tagesuntergrenzen bei Umverteilung gesund (kein Tag rutscht unter eine vertretbare + Mindestkalorienzahl)? Prüfe die `Math.max(...)`-Floors. + - Ist „Wochenziel = Tagesziel × 7" eine fachlich haltbare Vereinfachung? + +5. **Fasten / Trinkziel / Gewicht** + - Fasten-Stunden-Grenzen plausibel (0–24)? Wird Fasten neutral/sicher dargestellt? + - Trinkziel-Default und -Grenzen realistisch? + - Gewichtsprognosen: beruht eine evtl. Prognose auf der ~7000-kcal-pro-kg-Faustregel und + ist sie als grobe Schätzung gekennzeichnet? + +6. **Sprache & Motivation** + - Fördert die App gesundes, flexibles Verhalten (kein Diät-Stress, keine Verherrlichung von + starkem Defizit/Restriktion)? Das ist erklärtes Produktziel („flexibel, nicht strafend"). + +## Ausgabeformat + +Antworte strukturiert auf Deutsch, mit echten Umlauten: + +``` +## Ernährungsfachliche Abnahme: + +**Urteil:** ABGENOMMEN | ABGENOMMEN MIT AUFLAGEN | ABGELEHNT + +**Zusammenfassung:** <2–3 Sätze fachliches Gesamtbild> + +### Befunde +Pro Befund: Schweregrad (🔴 kritisch / 🟡 Auflage / 🟢 ok-Hinweis), Bereich, +Datei:Zeile, beobachtete Zahl/Formel, fachliche Bewertung, konkrete Empfehlung. + +### Auflagen (falls „mit Auflagen") +Nummerierte, umsetzbare Punkte, die für eine volle Abnahme nötig sind. + +### Haftungshinweis +Eine Zeile: private Tracking-App, kein Medizinprodukt; ärztliche/individuelle Beratung +bleibt maßgeblich. +``` + +Sei konkret und ehrlich: lieber „mit Auflagen" mit klaren Punkten als ein pauschales „passt schon". +Wenn dir eine Zahl fachlich grenzwertig erscheint, nenne den Referenzbereich, an dem du sie misst. diff --git a/CLAUDE.md b/CLAUDE.md index 733cc48..18e7c80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,9 @@ Hellth Hub ist eine mobile-first Health-, Ernährungs-, Aktivitäts- und Einkauf - Admin-App lokal: `http://localhost:3001` - API lokal: `http://localhost:3002` (Health: `/api/health` — `/` direkt liefert 404, ist normal) -- Docker-App: `http://localhost:3000` · Postgres: `localhost:5432` · pgAdmin: `http://localhost:5050` +- Postgres: `localhost:5432` · pgAdmin: `http://localhost:5050` + +Docker stellt nur `db` + `pgadmin` bereit (`docker/docker-compose.yml`). Lokale Entwicklung läuft über die Dev-Server auf 3001/3002. ## Lokaler Start diff --git a/apps/admin/app/(authed)/einstellungen/page.tsx b/apps/admin/app/(authed)/einstellungen/page.tsx index aabe163..68410af 100644 --- a/apps/admin/app/(authed)/einstellungen/page.tsx +++ b/apps/admin/app/(authed)/einstellungen/page.tsx @@ -19,7 +19,6 @@ 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"; import { SectionCard } from "./_components/section-card"; import { SettingsTabs, useSettingsTab } from "./_components/settings-tabs"; @@ -37,10 +36,51 @@ const dietPreferenceOptions = [ type DietPreference = (typeof dietPreferenceOptions)[number]["id"]; +type GoalsForm = { + calories: number; + proteinG: number; + carbsG: number; + fatG: number; + drinkMl: number; + weightKg: number; + fastingHours: number; + sportAddsCalories: boolean; +}; + +const defaultGoalsForm: GoalsForm = { + calories: 2000, + proteinG: 150, + carbsG: 200, + fatG: 67, + drinkMl: 2500, + weightKg: 120, + fastingHours: 12, + sportAddsCalories: true, +}; + +const goalsFields: Array<{ + key: keyof Omit; + label: string; + unit: string; + icon: typeof FireIcon; +}> = [ + { key: "calories", label: "Kalorien", 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); } +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + function generatePassword(length = 16) { const bytes = crypto.getRandomValues(new Uint8Array(length)); return Array.from(bytes, (byte) => PASSWORD_CHARS[byte % PASSWORD_CHARS.length]!).join(""); @@ -88,11 +128,9 @@ export default function EinstellungenPage() { 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(null); const [errorMessage, setErrorMessage] = useState(null); - const [healthGoals, setHealthGoals] = useState(null); const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [phone, setPhone] = useState(""); @@ -113,6 +151,7 @@ export default function EinstellungenPage() { const [meatFishFrequency, setMeatFishFrequency] = useState< "none" | "once_per_day" | "flexible" >("flexible"); + const [goalsForm, setGoalsForm] = useState(defaultGoalsForm); const isPlatformAdmin = profileQuery.data?.role === "admin"; @@ -137,11 +176,6 @@ export default function EinstellungenPage() { } }, [profileQuery.data]); - useEffect(() => { - if (!userKey) return; - setHealthGoals(loadHellthData(userKey).goals); - }, [userKey]); - const updateMyProfile = trpc.users.updateMyProfile.useMutation({ onSuccess: async () => { await Promise.all([ @@ -184,6 +218,18 @@ export default function EinstellungenPage() { }, }); + const upsertGoals = trpc.health.upsertGoals.useMutation({ + onSuccess: async () => { + await healthOverviewQuery.refetch(); + setStatusMessage("Ziele gespeichert."); + setErrorMessage(null); + }, + onError: (error) => { + setErrorMessage(error.message); + setStatusMessage(null); + }, + }); + useEffect(() => { const profile = healthOverviewQuery.data?.profile; if (!profile) return; @@ -200,6 +246,21 @@ export default function EinstellungenPage() { ); }, [healthOverviewQuery.data?.profile]); + useEffect(() => { + const apiGoals = healthOverviewQuery.data?.goals; + if (!apiGoals) return; + setGoalsForm({ + calories: apiGoals.calories ?? defaultGoalsForm.calories, + proteinG: apiGoals.proteinG ?? defaultGoalsForm.proteinG, + carbsG: apiGoals.carbsG ?? defaultGoalsForm.carbsG, + fatG: apiGoals.fatG ?? defaultGoalsForm.fatG, + drinkMl: apiGoals.drinkMl ?? defaultGoalsForm.drinkMl, + weightKg: Number(apiGoals.weightKg ?? defaultGoalsForm.weightKg), + fastingHours: apiGoals.fastingHours ?? defaultGoalsForm.fastingHours, + sportAddsCalories: apiGoals.sportAddsCalories ?? true, + }); + }, [healthOverviewQuery.data?.goals]); + function toggleDietPreference(preference: DietPreference) { setDietPreferences((current) => { const next = current.includes(preference) @@ -211,19 +272,27 @@ export default function EinstellungenPage() { function saveEatingPreferences() { const profile = healthOverviewQuery.data?.profile; + // Groesse/Gewicht sind optional und muessen > 0 sein. Alte Profile koennen + // hier noch 0 gespeichert haben; solche Werte als null behandeln, damit das + // Speichern der Essenspraeferenzen nicht an der Zod-Validierung scheitert. + const positiveOrNull = (value: number | string | null | undefined) => { + const num = value == null ? null : Number(value); + return num != null && num > 0 ? num : null; + }; 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), + heightCm: positiveOrNull(profile?.heightCm), + currentWeightKg: positiveOrNull(profile?.currentWeightKg), + targetWeightKg: positiveOrNull(profile?.targetWeightKg), activityLevel: profile?.activityLevel ?? "moderate", dietStyle: profile?.dietStyle ?? "flexible", dietPreferences, - mealsPerDay: Number(mealsPerDay) || 3, - cooksPerDay: Number(cooksPerDay) || 1, - preferredMealPrepDays: Number(preferredMealPrepDays) || 0, + // An die Zod-Grenzen klemmen, damit eine zu grosse Eingabe nicht die + // ganze Mutation abweist (mealsPerDay 1-8, cooksPerDay 1-4, Prep 0-7). + mealsPerDay: clamp(Number(mealsPerDay) || 3, 1, 8), + cooksPerDay: clamp(Number(cooksPerDay) || 1, 1, 4), + preferredMealPrepDays: clamp(Number(preferredMealPrepDays) || 0, 0, 7), meatFishFrequency, cookingTime: (profile?.cookingTime as "low" | "medium" | "high" | undefined) ?? "medium", @@ -235,6 +304,21 @@ export default function EinstellungenPage() { }); } + function saveGoals() { + upsertGoals.mutate({ + drinkMl: goalsForm.drinkMl, + calories: goalsForm.calories, + carbsG: goalsForm.carbsG, + fatG: goalsForm.fatG, + proteinG: goalsForm.proteinG, + weightKg: goalsForm.weightKg || null, + fastingHours: goalsForm.fastingHours, + // Wochenbudget wird automatisch aus dem Tagesziel abgeleitet (kein eigenes Feld mehr). + weeklyCalories: goalsForm.calories * 7, + sportAddsCalories: goalsForm.sportAddsCalories, + }); + } + const isBusy = updateMyProfile.isPending || changeMyPassword.isPending; const passwordMismatch = confirmNewPassword.length > 0 && newPassword !== confirmNewPassword; @@ -568,57 +652,60 @@ export default function EinstellungenPage() { {active === "ziele" ? ( -
- {[ - { - icon: FireIcon, - label: "Kalorien", - value: `${healthGoals?.calories ?? 2000} kcal`, - color: "text-orange-500", - }, - { - icon: ScaleIcon, - label: "Makros (KH / Fett / Eiweiß)", - 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-cyan-500", - }, - { - icon: ScaleIcon, - label: "Gewicht", - value: `${healthGoals?.weightKg ?? 120} kg`, - color: "text-emerald-500", - }, - ].map((item) => ( - + +
+ } + > +
+ {goalsFields.map((field) => ( + ))}
+ {isPlatformAdmin ? (
Als Admin kannst du die{" "} diff --git a/apps/admin/app/(authed)/page.tsx b/apps/admin/app/(authed)/page.tsx index f371536..c335e87 100644 --- a/apps/admin/app/(authed)/page.tsx +++ b/apps/admin/app/(authed)/page.tsx @@ -2,6 +2,7 @@ import { BeakerIcon, + BoltIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, @@ -68,6 +69,7 @@ const fallbackGoals = { fat: 67, protein: 150, fastingHours: 12, + sportAddsCalories: true, }; function toDateKey(date: Date) { @@ -303,7 +305,9 @@ export default function DashboardPage() { function invalidateDay() { utils.health.overview.invalidate({ date: selectedDate }); - utils.health.statsSummary.invalidate({ referenceDate: todayKey }); + // Alle statsSummary-Varianten invalidieren, damit Wochenbudget-Seite und + // Statistiken konsistent bleiben (unterschiedliche referenceDate-Caches). + utils.health.statsSummary.invalidate(); } const addMealMutation = trpc.health.addMealLog.useMutation({ onSuccess: invalidateDay }); @@ -326,6 +330,7 @@ export default function DashboardPage() { fat: Number(overviewQuery.data.goals.fatG) || fallbackGoals.fat, protein: Number(overviewQuery.data.goals.proteinG) || fallbackGoals.protein, fastingHours: overviewQuery.data.goals.fastingHours, + sportAddsCalories: overviewQuery.data.goals.sportAddsCalories ?? true, } : fallbackGoals; @@ -368,9 +373,21 @@ export default function DashboardPage() { return { calories, protein, drinkMl, carbs, fat }; }, [day.drinks, day.foods]); - const remainingCalories = Math.max(0, goals.calories - totals.calories); + // Sportbonus des AUSGEWAEHLTEN Tages (nicht der ganzen Woche). Wird nur dann + // aufs Tagesziel addiert, wenn der Nutzer das in den Zielen aktiviert hat. + const dayActivityCalories = + weeklyBudget?.days.find((entry) => entry.date === selectedDate)?.activityCalories ?? 0; + const dailySportBonus = goals.sportAddsCalories ? dayActivityCalories : 0; + const dailyCalorieGoal = goals.calories + dailySportBonus; + const remainingCalories = Math.max(0, dailyCalorieGoal - totals.calories); const nutritionScore = Math.min(100, Math.round((progress(totals.protein, goals.protein) + progress(totals.drinkMl, goals.drinkMl)) / 2)); + // Belohnende Hinweise: nur fuer vergangene/heutige Tage und nur wenn schon + // etwas getrackt wurde, damit "Defizit" am leeren Morgen nicht falsch jubelt. + const calorieDeficit = dailyCalorieGoal - totals.calories; + const showDeficitPraise = !isFuture && totals.calories > 0 && calorieDeficit > 0; + const showSportPraise = !isFuture && dailySportBonus > 0; + function addFoodEntry() { if (!isEditable || !foodForm.name.trim()) return; addMealMutation.mutate({ @@ -478,13 +495,47 @@ export default function DashboardPage() {

{weeklyBudget.baseBudget.toLocaleString("de-DE")}

Ziel kcal

{weeklyBudget.eatenCalories.toLocaleString("de-DE")}

gegessen/geplant

-

+{weeklyBudget.activityCalories.toLocaleString("de-DE")}

Sportbonus

+
+
+ +

+{weeklyBudget.activityCalories.toLocaleString("de-DE")}

+
+

Sportbonus (ganze Woche)

+

{weeklyBudget.availableCalories.toLocaleString("de-DE")}

verfügbar

+ {weeklyBudget.activityCalories > 0 ? ( +

+ + Der Sportbonus summiert alle Aktivitätskalorien dieser Woche und erhöht dein verfügbares Budget. +

+ ) : null} ) : null}
+ {showSportPraise || showDeficitPraise ? ( +
+ {showSportPraise ? ( +
+ ⚡ + + Stark! Du hast dir heute {dailySportBonus.toLocaleString("de-DE")} kcal + Sportbonus erkämpft. Weiter so! + +
+ ) : null} + {showDeficitPraise ? ( +
+ 🎉 + + {calorieDeficit.toLocaleString("de-DE")} kcal im Plus — du liegst im + Defizit. Gute Arbeit für deine Ziele! + +
+ ) : null} +
+ ) : null}
@@ -494,6 +545,12 @@ export default function DashboardPage() {

{remainingCalories.toLocaleString("de-DE")} kcal

+ {dailySportBonus > 0 ? ( +
+ + +{dailySportBonus.toLocaleString("de-DE")} kcal durch Sport heute +
+ ) : null}
diff --git a/apps/admin/app/(authed)/ziele/page.tsx b/apps/admin/app/(authed)/ziele/page.tsx deleted file mode 100644 index 4eb4594..0000000 --- a/apps/admin/app/(authed)/ziele/page.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"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; 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(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 ( -
-
-

Health-API

-

Ziele

-

- Alltagstaugliche Zielwerte statt perfekter Wissenschaft. Diese Werte werden jetzt serverseitig pro Benutzer gespeichert. -

-
- -
- {overviewQuery.isLoading ? ( -
Ziele werden geladen...
- ) : null} -
- {fields.map((field) => ( - - ))} -
- - - {saved ?

Gespeichert.

: null} - {saveGoalsMutation.error ?

{saveGoalsMutation.error.message}

: null} -
-
- ); -} diff --git a/apps/admin/app/_components/sidebar.tsx b/apps/admin/app/_components/sidebar.tsx index f426ba7..dac60b7 100644 --- a/apps/admin/app/_components/sidebar.tsx +++ b/apps/admin/app/_components/sidebar.tsx @@ -50,7 +50,6 @@ const navGroups: NavGroup[] = [ { href: "/wearables", label: "Wearables", icon: DevicePhoneMobileIcon }, { href: "/hellfireclub", label: "Hellfireclub", icon: FireIcon }, { href: "/gewicht", label: "Gewicht", icon: ScaleIcon }, - { href: "/ziele", label: "Ziele", icon: ChartBarIcon }, ], }, { diff --git a/packages/api/src/trpc/health.test.ts b/packages/api/src/trpc/health.test.ts index eee4fef..b960ee4 100644 --- a/packages/api/src/trpc/health.test.ts +++ b/packages/api/src/trpc/health.test.ts @@ -671,3 +671,103 @@ test("health.addActivityLog schreibt Hellfireclub-Punkte mit Quelle und Idempote ); }); +test("health.upsertProfile speichert fehlende Groesse/Gewicht als null statt 0", async () => { + const { db, calls } = createHealthDbMock(); + const caller = appRouter.createCaller(createContext({ db }) as never); + + await caller.health.upsertProfile({ + goal: "healthier", + age: null, + heightCm: null, + currentWeightKg: null, + targetWeightKg: null, + activityLevel: "moderate", + dietStyle: "flexible", + dietPreferences: ["vegan", "vegetarian", "meat", "fish"], + mealsPerDay: 3, + cooksPerDay: 1, + preferredMealPrepDays: 0, + meatFishFrequency: "flexible", + cookingTime: "medium", + budgetFocus: false, + excludedFoods: null, + biggestChallenge: null, + medicalNote: false, + completed: false, + }); + + const values = calls.find((call) => call.op === "insert.values")?.payload as { + heightCm?: string | null; + currentWeightKg?: string | null; + targetWeightKg?: string | null; + }; + // Regression: frueher wurde hier "0" geschrieben, was beim erneuten Speichern + // an z.number().positive() scheiterte (Round-Trip-Bug). + assert.equal(values.heightCm, null); + assert.equal(values.currentWeightKg, null); + assert.equal(values.targetWeightKg, null); +}); + +test("health.upsertProfile uebernimmt gesetzte Groesse/Gewicht als String", async () => { + const { db, calls } = createHealthDbMock(); + const caller = appRouter.createCaller(createContext({ db }) as never); + + await caller.health.upsertProfile({ + goal: "lose_weight", + age: 35, + heightCm: 180, + currentWeightKg: 82.5, + targetWeightKg: 78, + activityLevel: "active", + dietStyle: "flexible", + dietPreferences: ["meat", "fish"], + mealsPerDay: 4, + cooksPerDay: 2, + preferredMealPrepDays: 3, + meatFishFrequency: "once_per_day", + cookingTime: "medium", + budgetFocus: false, + excludedFoods: null, + biggestChallenge: null, + medicalNote: false, + completed: true, + }); + + const values = calls.find((call) => call.op === "insert.values")?.payload as { + heightCm?: string | null; + currentWeightKg?: string | null; + targetWeightKg?: string | null; + }; + assert.equal(values.heightCm, "180"); + assert.equal(values.currentWeightKg, "82.5"); + assert.equal(values.targetWeightKg, "78"); +}); + +test("health.upsertProfile weist mealsPerDay groesser 8 ab", async () => { + const caller = appRouter.createCaller(createContext() as never); + + await assert.rejects( + () => + caller.health.upsertProfile({ + goal: "healthier", + age: null, + heightCm: null, + currentWeightKg: null, + targetWeightKg: null, + activityLevel: "moderate", + dietStyle: "flexible", + dietPreferences: ["vegan"], + mealsPerDay: 9, + cooksPerDay: 1, + preferredMealPrepDays: 0, + meatFishFrequency: "flexible", + cookingTime: "medium", + budgetFocus: false, + excludedFoods: null, + biggestChallenge: null, + medicalNote: false, + completed: false, + }) as never, + ); +}); + diff --git a/packages/api/src/trpc/routers/health/dashboard.ts b/packages/api/src/trpc/routers/health/dashboard.ts index ac19f80..3c0beb5 100644 --- a/packages/api/src/trpc/routers/health/dashboard.ts +++ b/packages/api/src/trpc/routers/health/dashboard.ts @@ -120,8 +120,8 @@ export const dashboardRouter = router({ .values({ userId, ...input, - heightCm: numberString(input.heightCm, 0), - currentWeightKg: numberString(input.currentWeightKg, 0), + heightCm: input.heightCm ? numberString(input.heightCm) : null, + currentWeightKg: input.currentWeightKg ? numberString(input.currentWeightKg) : null, targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null, completedAt: input.completed ? new Date() : null, updatedAt: new Date(), @@ -130,8 +130,8 @@ export const dashboardRouter = router({ target: schema.healthProfiles.userId, set: { ...input, - heightCm: numberString(input.heightCm, 0), - currentWeightKg: numberString(input.currentWeightKg, 0), + heightCm: input.heightCm ? numberString(input.heightCm) : null, + currentWeightKg: input.currentWeightKg ? numberString(input.currentWeightKg) : null, targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null, completedAt: input.completed ? new Date() : null, updatedAt: new Date(),