Feature: Ziele in Einstellungen-Tab, Sportbonus-Anzeige, Profil-Fix
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) <noreply@anthropic.com>
This commit is contained in:
parent
e54a2ac223
commit
e264765876
9 changed files with 430 additions and 215 deletions
|
|
@ -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")
|
||||
|
|
|
|||
107
.claude/skills/ernaehrungswissenschaftler/SKILL.md
Normal file
107
.claude/skills/ernaehrungswissenschaftler/SKILL.md
Normal file
|
|
@ -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: <Scope>
|
||||
|
||||
**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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<GoalsForm, "sportAddsCalories">;
|
||||
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<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("");
|
||||
|
|
@ -113,6 +151,7 @@ export default function EinstellungenPage() {
|
|||
const [meatFishFrequency, setMeatFishFrequency] = useState<
|
||||
"none" | "once_per_day" | "flexible"
|
||||
>("flexible");
|
||||
const [goalsForm, setGoalsForm] = useState<GoalsForm>(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" ? (
|
||||
<SectionCard
|
||||
title="Ziele"
|
||||
description="Kalorien, Makros, Wasser, Fasten und Gewicht werden unter /ziele detailliert eingestellt."
|
||||
>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href="/ziele"
|
||||
className="flex w-full items-center gap-4 py-4 text-left transition-colors hover:bg-gray-50"
|
||||
description="Alltagstaugliche Zielwerte für Kalorien, Makros, Wasser, Fasten und Gewicht. Das Wochenbudget wird automatisch als Tagesziel × 7 berechnet."
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveGoals}
|
||||
disabled={upsertGoals.isPending}
|
||||
className={buttonPrimary}
|
||||
>
|
||||
<item.icon className={`h-6 w-6 ${item.color}`} />
|
||||
<span className="flex-1 text-sm font-semibold text-gray-900">
|
||||
{item.label}
|
||||
{upsertGoals.isPending ? "Speichere…" : "Ziele speichern"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{goalsFields.map((field) => (
|
||||
<label key={field.key} className="flex items-center gap-3">
|
||||
<field.icon className="h-6 w-6 shrink-0 text-[#0F766E]" />
|
||||
<span className="flex-1 text-sm font-medium text-gray-700">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-500">{item.value}</span>
|
||||
<ChevronRightIcon className="h-4 w-4 text-gray-300" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={goalsForm[field.key]}
|
||||
onChange={(event) =>
|
||||
setGoalsForm((current) => ({
|
||||
...current,
|
||||
[field.key]: Number(event.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
className={`${inputClass} w-28 text-right`}
|
||||
/>
|
||||
<span className="w-10 text-sm font-medium text-gray-400">
|
||||
{field.unit}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-3 rounded-lg bg-gray-50 px-4 py-3 text-sm font-medium text-gray-700">
|
||||
Sportkalorien erhöhen das Tagesbudget
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={goalsForm.sportAddsCalories}
|
||||
onChange={(event) =>
|
||||
setGoalsForm((current) => ({
|
||||
...current,
|
||||
sportAddsCalories: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{isPlatformAdmin ? (
|
||||
<div className="rounded-lg border border-gray-100 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||
Als Admin kannst du die{" "}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<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-emerald-50 p-4">
|
||||
<div className="flex items-center gap-1.5 text-emerald-700">
|
||||
<BoltIcon className="h-5 w-5" />
|
||||
<p className="text-2xl font-black">+{weeklyBudget.activityCalories.toLocaleString("de-DE")}</p>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-emerald-700">Sportbonus (ganze Woche)</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>
|
||||
{weeklyBudget.activityCalories > 0 ? (
|
||||
<p className="mt-4 flex items-center gap-1.5 text-xs font-semibold text-gray-400">
|
||||
<BoltIcon className="h-4 w-4 text-emerald-600" />
|
||||
Der Sportbonus summiert alle Aktivitätskalorien dieser Woche und erhöht dein verfügbares Budget.
|
||||
</p>
|
||||
) : null}
|
||||
</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">
|
||||
{showSportPraise || showDeficitPraise ? (
|
||||
<div className="mb-5 space-y-2">
|
||||
{showSportPraise ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">
|
||||
<span className="text-xl" aria-hidden>⚡</span>
|
||||
<span>
|
||||
Stark! Du hast dir heute {dailySportBonus.toLocaleString("de-DE")} kcal
|
||||
Sportbonus erkämpft. Weiter so!
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{showDeficitPraise ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-amber-50 px-4 py-3 text-sm font-bold text-amber-800">
|
||||
<span className="text-xl" aria-hidden>🎉</span>
|
||||
<span>
|
||||
{calorieDeficit.toLocaleString("de-DE")} kcal im Plus — du liegst im
|
||||
Defizit. Gute Arbeit für deine Ziele!
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
|
|
@ -494,6 +545,12 @@ export default function DashboardPage() {
|
|||
<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>
|
||||
{dailySportBonus > 0 ? (
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1.5 text-sm font-bold text-emerald-700">
|
||||
<BoltIcon className="h-4 w-4" />
|
||||
+{dailySportBonus.toLocaleString("de-DE")} kcal durch Sport heute
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -525,7 +582,12 @@ export default function DashboardPage() {
|
|||
</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>
|
||||
<span>
|
||||
Ziel: {goals.calories.toLocaleString("de-DE")} kcal
|
||||
{dailySportBonus > 0
|
||||
? ` + ${dailySportBonus.toLocaleString("de-DE")} Sport = ${dailyCalorieGoal.toLocaleString("de-DE")} kcal`
|
||||
: ""}
|
||||
</span>
|
||||
<EllipsisHorizontalIcon className="h-7 w-7" />
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue