Compare commits

..

No commits in common. "70f59cfd40ed082289461436c42b1bdbb74443b0" and "e42a8109bfe12eaa1ace579dc23023be9727e971" have entirely different histories.

23 changed files with 401 additions and 5395 deletions

View file

@ -92,4 +92,4 @@ $env:NEXT_PUBLIC_API_URL='http://localhost:3002'; pnpm --filter admin build
- Bestehende User-Änderungen nicht zurücksetzen - Bestehende User-Änderungen nicht zurücksetzen
- Keine destruktiven Git-Befehle ohne ausdrückliche Anweisung - Keine destruktiven Git-Befehle ohne ausdrückliche Anweisung
- Cleanup an Altlasten nur nach expliziter Freigabe (siehe `docs/projektreferenz.md` Abschnitt "Bekannte Altlasten") - Cleanup an Altlasten nur nach expliziter Freigabe (siehe `claude-info.md` Abschnitt "Bekannte Altlasten")

View file

@ -1,107 +0,0 @@
---
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.

3
.gitignore vendored
View file

@ -12,9 +12,6 @@ build/
coverage/ coverage/
*.tsbuildinfo *.tsbuildinfo
# Next.js auto-generated (siehe https://nextjs.org/docs/app/api-reference/config/typescript)
next-env.d.ts
# Logs # Logs
*.log *.log
.logs/ .logs/

View file

@ -1,42 +1,11 @@
# Claude Working Guidelines - Hellth Hub # Claude Working Guidelines - Hellth Hub
Hellth Hub ist eine mobile-first Health-, Ernährungs-, Aktivitäts- und Einkaufs-Tracking-App für den Home-Lab-Betrieb. Monorepo mit `pnpm`: Next.js 16 / React 19 Frontend (`apps/admin`), Hono + tRPC + Better Auth API (`packages/api`), Drizzle/PostgreSQL (`packages/db`). Hellth Hub ist eine mobile-first Health- und Ernährungstracking-App für den Home-Lab-Betrieb.
**Ausführliche Projektreferenz** (Dateilandkarte, Features, Altlasten): [`docs/projektreferenz.md`](docs/projektreferenz.md). Wichtig:
## Grundregeln - Umlaute immer korrekt schreiben.
- Keine alten Altprojekt-/Tenant-/Community-Begriffe in neue UI oder Doku übernehmen.
- Umlaute immer korrekt schreiben: echte `ä ö ü Ä Ö Ü ß` statt `ae oe ue ss`. Gilt für UI, Doku, Commits und Antworten.
- Keine alten Altprojekt-/Tenant-/Community-/Naturfreunde-Begriffe in neue UI oder Doku übernehmen.
- Einstellungen und Health-Daten sind benutzerbezogen, nicht global. - Einstellungen und Health-Daten sind benutzerbezogen, nicht global.
- Bestehende Architektur nutzen: `apps/admin`, `packages/api`, `packages/db`. - Bestehende Architektur nutzen: `apps/admin`, `packages/api`, `packages/db`.
- Änderungen mit Tests absichern, besonders bei Auth, Health-Daten, Wochenbudget und Einkaufslisten. - Änderungen mit Tests absichern, besonders bei Auth, Health-Daten, Wochenbudget und Einkaufslisten.
- Bestehende User-Änderungen nicht zurücksetzen. Keine destruktiven Git-Befehle ohne ausdrückliche Anweisung.
- Cleanup von Altlasten nur nach ausdrücklicher Freigabe.
- Neue Formulare nach Möglichkeit mit `FormField` aus `apps/admin/app/_components/form-field.tsx` bauen.
- Bei neuen DB-Funktionen Schema, Migration, API und UI zusammen denken.
- UI mobile-first denken.
## Ports
- Admin-App lokal: `http://localhost:3001`
- API lokal: `http://localhost:3002` (Health: `/api/health` — `/` direkt liefert 404, ist normal)
- 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
Docker/Postgres muss laufen (`docker ps`), dann:
```powershell
pnpm --filter @hellth/db db:migrate # Migrationen
pnpm --filter @hellth/api dev # API (Port 3002)
pnpm --filter admin dev # Admin (Port 3001)
```
Tests: `pnpm --filter admin test` · `pnpm --filter @hellth/api test`. Typecheck: `pnpm typecheck`.
## Auth/Admin
Admin-User: `admin@onl1.eu`. Admin erzwingt 2FA (mit Logs/Sicherheitsmechanik bei 2FA-Verlust). Passwörter nicht dokumentieren; bei Bedarf über Seed-/Admin-Skripte neu setzen.

View file

@ -11,8 +11,8 @@ export type SettingsTabId =
| "anwendung"; | "anwendung";
const tabs: { id: SettingsTabId; label: string; description: string }[] = [ const tabs: { id: SettingsTabId; label: string; description: string }[] = [
{ id: "profil", label: "Profil", description: "Persönliche Daten und Kontakt" }, { id: "profil", label: "Profil", description: "Persoenliche Daten und Kontakt" },
{ id: "ernaehrung", label: "Ernährung", description: "Rezept- und Mahlzeiten-Präferenzen" }, { id: "ernaehrung", label: "Ernaehrung", description: "Rezept- und Mahlzeiten-Praeferenzen" },
{ id: "ziele", label: "Ziele", description: "Kalorien, Makros, Gewicht, Fasten" }, { id: "ziele", label: "Ziele", description: "Kalorien, Makros, Gewicht, Fasten" },
{ id: "sicherheit", label: "Sicherheit", description: "2FA, Passkeys, Passwort" }, { id: "sicherheit", label: "Sicherheit", description: "2FA, Passkeys, Passwort" },
{ id: "anwendung", label: "Anwendung", description: "Erscheinungsbild und Erinnerungen" }, { id: "anwendung", label: "Anwendung", description: "Erscheinungsbild und Erinnerungen" },

View file

@ -19,6 +19,7 @@ import { useEffect, useMemo, useState } from "react";
import { DataPageLayout } from "@/app/_components/data-page-layout"; import { DataPageLayout } from "@/app/_components/data-page-layout";
import { useTheme } from "@/app/_components/theme-provider"; import { useTheme } from "@/app/_components/theme-provider";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { HellthGoals, loadHellthData } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { SectionCard } from "./_components/section-card"; import { SectionCard } from "./_components/section-card";
import { SettingsTabs, useSettingsTab } from "./_components/settings-tabs"; import { SettingsTabs, useSettingsTab } from "./_components/settings-tabs";
@ -36,51 +37,10 @@ const dietPreferenceOptions = [
type DietPreference = (typeof dietPreferenceOptions)[number]["id"]; 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() { function todayKey() {
return new Date().toISOString().slice(0, 10); 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) { function generatePassword(length = 16) {
const bytes = crypto.getRandomValues(new Uint8Array(length)); const bytes = crypto.getRandomValues(new Uint8Array(length));
return Array.from(bytes, (byte) => PASSWORD_CHARS[byte % PASSWORD_CHARS.length]!).join(""); return Array.from(bytes, (byte) => PASSWORD_CHARS[byte % PASSWORD_CHARS.length]!).join("");
@ -97,7 +57,7 @@ async function toOptimizedAvatarDataUrl(file: File) {
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas nicht verfügbar"); if (!ctx) throw new Error("Canvas nicht verfuegbar");
ctx.drawImage(bitmap, 0, 0, width, height); ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close(); bitmap.close();
@ -107,7 +67,7 @@ async function toOptimizedAvatarDataUrl(file: File) {
throw new Error("Profilbild konnte nicht verarbeitet werden."); throw new Error("Profilbild konnte nicht verarbeitet werden.");
} }
if (dataUrl.length > 120_000) { if (dataUrl.length > 120_000) {
throw new Error("Profilbild ist nach Komprimierung noch zu groß."); throw new Error("Profilbild ist nach Komprimierung noch zu gross.");
} }
return dataUrl; return dataUrl;
} }
@ -128,9 +88,11 @@ export default function EinstellungenPage() {
const securityQuery = trpc.security.status.useQuery(); const securityQuery = trpc.security.status.useQuery();
const healthOverviewQuery = trpc.health.overview.useQuery({ date: todayKey() }); const healthOverviewQuery = trpc.health.overview.useQuery({ date: todayKey() });
const passkeysQuery = authClient.useListPasskeys(); const passkeysQuery = authClient.useListPasskeys();
const userKey = profileQuery.data?.id ?? profileQuery.data?.email ?? null;
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [healthGoals, setHealthGoals] = useState<HellthGoals | null>(null);
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState(""); const [lastName, setLastName] = useState("");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
@ -151,7 +113,6 @@ export default function EinstellungenPage() {
const [meatFishFrequency, setMeatFishFrequency] = useState< const [meatFishFrequency, setMeatFishFrequency] = useState<
"none" | "once_per_day" | "flexible" "none" | "once_per_day" | "flexible"
>("flexible"); >("flexible");
const [goalsForm, setGoalsForm] = useState<GoalsForm>(defaultGoalsForm);
const isPlatformAdmin = profileQuery.data?.role === "admin"; const isPlatformAdmin = profileQuery.data?.role === "admin";
@ -176,6 +137,11 @@ export default function EinstellungenPage() {
} }
}, [profileQuery.data]); }, [profileQuery.data]);
useEffect(() => {
if (!userKey) return;
setHealthGoals(loadHellthData(userKey).goals);
}, [userKey]);
const updateMyProfile = trpc.users.updateMyProfile.useMutation({ const updateMyProfile = trpc.users.updateMyProfile.useMutation({
onSuccess: async () => { onSuccess: async () => {
await Promise.all([ await Promise.all([
@ -209,19 +175,7 @@ export default function EinstellungenPage() {
onSuccess: async () => { onSuccess: async () => {
await healthOverviewQuery.refetch(); await healthOverviewQuery.refetch();
await utils.health.recipeRecommendations.invalidate(); await utils.health.recipeRecommendations.invalidate();
setStatusMessage("Essenspräferenzen gespeichert."); setStatusMessage("Essenspraeferenzen gespeichert.");
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error.message);
setStatusMessage(null);
},
});
const upsertGoals = trpc.health.upsertGoals.useMutation({
onSuccess: async () => {
await healthOverviewQuery.refetch();
setStatusMessage("Ziele gespeichert.");
setErrorMessage(null); setErrorMessage(null);
}, },
onError: (error) => { onError: (error) => {
@ -246,21 +200,6 @@ export default function EinstellungenPage() {
); );
}, [healthOverviewQuery.data?.profile]); }, [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) { function toggleDietPreference(preference: DietPreference) {
setDietPreferences((current) => { setDietPreferences((current) => {
const next = current.includes(preference) const next = current.includes(preference)
@ -272,27 +211,19 @@ export default function EinstellungenPage() {
function saveEatingPreferences() { function saveEatingPreferences() {
const profile = healthOverviewQuery.data?.profile; 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({ updateHealthProfile.mutate({
goal: profile?.goal ?? "healthier", goal: profile?.goal ?? "healthier",
age: profile?.age ?? null, age: profile?.age ?? null,
heightCm: positiveOrNull(profile?.heightCm), heightCm: profile?.heightCm == null ? null : Number(profile.heightCm),
currentWeightKg: positiveOrNull(profile?.currentWeightKg), currentWeightKg:
targetWeightKg: positiveOrNull(profile?.targetWeightKg), profile?.currentWeightKg == null ? null : Number(profile.currentWeightKg),
targetWeightKg: profile?.targetWeightKg == null ? null : Number(profile.targetWeightKg),
activityLevel: profile?.activityLevel ?? "moderate", activityLevel: profile?.activityLevel ?? "moderate",
dietStyle: profile?.dietStyle ?? "flexible", dietStyle: profile?.dietStyle ?? "flexible",
dietPreferences, dietPreferences,
// An die Zod-Grenzen klemmen, damit eine zu grosse Eingabe nicht die mealsPerDay: Number(mealsPerDay) || 3,
// ganze Mutation abweist (mealsPerDay 1-8, cooksPerDay 1-4, Prep 0-7). cooksPerDay: Number(cooksPerDay) || 1,
mealsPerDay: clamp(Number(mealsPerDay) || 3, 1, 8), preferredMealPrepDays: Number(preferredMealPrepDays) || 0,
cooksPerDay: clamp(Number(cooksPerDay) || 1, 1, 4),
preferredMealPrepDays: clamp(Number(preferredMealPrepDays) || 0, 0, 7),
meatFishFrequency, meatFishFrequency,
cookingTime: cookingTime:
(profile?.cookingTime as "low" | "medium" | "high" | undefined) ?? "medium", (profile?.cookingTime as "low" | "medium" | "high" | undefined) ?? "medium",
@ -304,21 +235,6 @@ 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 isBusy = updateMyProfile.isPending || changeMyPassword.isPending;
const passwordMismatch = const passwordMismatch =
confirmNewPassword.length > 0 && newPassword !== confirmNewPassword; confirmNewPassword.length > 0 && newPassword !== confirmNewPassword;
@ -373,11 +289,11 @@ export default function EinstellungenPage() {
async function handleImageUpload(file: File) { async function handleImageUpload(file: File) {
if (!file.type.startsWith("image/")) { if (!file.type.startsWith("image/")) {
setErrorMessage("Bitte eine Bilddatei auswählen."); setErrorMessage("Bitte eine Bilddatei auswaehlen.");
return; return;
} }
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
setErrorMessage("Profilbild ist zu groß. Maximal 5 MB."); setErrorMessage("Profilbild ist zu gross. Maximal 5 MB.");
return; return;
} }
try { try {
@ -417,7 +333,7 @@ export default function EinstellungenPage() {
return ( return (
<DataPageLayout <DataPageLayout
title="Einstellungen" title="Einstellungen"
description="Profil, Sicherheit und persönliche Vorlieben" description="Profil, Sicherheit und persoenliche Vorlieben"
> >
<div className="space-y-6"> <div className="space-y-6">
<SettingsTabs active={active} onChange={setActive} /> <SettingsTabs active={active} onChange={setActive} />
@ -433,7 +349,7 @@ export default function EinstellungenPage() {
{active === "profil" ? ( {active === "profil" ? (
<SectionCard <SectionCard
title="Mein Profil" title="Mein Profil"
description="Persönliche Daten und Kontakt. E-Mail-Änderung erfolgt über das Konto." description="Persoenliche Daten und Kontakt. E-Mail-Aenderung erfolgt ueber das Konto."
footer={ footer={
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
@ -531,12 +447,12 @@ export default function EinstellungenPage() {
/> />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className={labelClass}>Straße und Hausnummer</label> <label className={labelClass}>Strasse und Hausnummer</label>
<input <input
className={inputClass} className={inputClass}
value={street} value={street}
onChange={(event) => setStreet(event.target.value)} onChange={(event) => setStreet(event.target.value)}
placeholder="Straße und Hausnummer" placeholder="Strasse und Hausnummer"
/> />
</div> </div>
<div> <div>
@ -564,7 +480,7 @@ export default function EinstellungenPage() {
{active === "ernaehrung" ? ( {active === "ernaehrung" ? (
<SectionCard <SectionCard
title="Rezept- und Mahlzeiten-Präferenzen" title="Rezept- und Mahlzeiten-Praeferenzen"
description="Beeinflusst Vorschlaege und automatische Wochenplanung." description="Beeinflusst Vorschlaege und automatische Wochenplanung."
footer={ footer={
<div className="flex justify-end"> <div className="flex justify-end">
@ -574,13 +490,13 @@ export default function EinstellungenPage() {
disabled={updateHealthProfile.isPending} disabled={updateHealthProfile.isPending}
className={buttonPrimary} className={buttonPrimary}
> >
Präferenzen speichern Praeferenzen speichern
</button> </button>
</div> </div>
} }
> >
<div> <div>
<p className={labelClass}>Ernährung</p> <p className={labelClass}>Ernaehrung</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{dietPreferenceOptions.map((option) => { {dietPreferenceOptions.map((option) => {
const selected = dietPreferences.includes(option.id); const selected = dietPreferences.includes(option.id);
@ -612,7 +528,7 @@ export default function EinstellungenPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<span className={labelClass}>Kochvorgänge/Tag</span> <span className={labelClass}>Kochvorgaenge/Tag</span>
<input <input
className={inputClass} className={inputClass}
inputMode="numeric" inputMode="numeric"
@ -652,60 +568,57 @@ export default function EinstellungenPage() {
{active === "ziele" ? ( {active === "ziele" ? (
<SectionCard <SectionCard
title="Ziele" title="Ziele"
description="Alltagstaugliche Zielwerte für Kalorien, Makros, Wasser, Fasten und Gewicht. Das Wochenbudget wird automatisch als Tagesziel × 7 berechnet." description="Kalorien, Makros, Wasser, Fasten und Gewicht werden unter /ziele detailliert eingestellt."
footer={
<div className="flex justify-end">
<button
type="button"
onClick={saveGoals}
disabled={upsertGoals.isPending}
className={buttonPrimary}
>
{upsertGoals.isPending ? "Speichere…" : "Ziele speichern"}
</button>
</div>
}
> >
<div className="space-y-4"> <div className="divide-y divide-gray-100">
{goalsFields.map((field) => ( {[
<label key={field.key} className="flex items-center gap-3"> {
<field.icon className="h-6 w-6 shrink-0 text-[#0F766E]" /> icon: FireIcon,
<span className="flex-1 text-sm font-medium text-gray-700"> label: "Kalorien",
{field.label} value: `${healthGoals?.calories ?? 2000} kcal`,
color: "text-orange-500",
},
{
icon: ScaleIcon,
label: "Makros (KH / Fett / Eiweiss)",
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"
>
<item.icon className={`h-6 w-6 ${item.color}`} />
<span className="flex-1 text-sm font-semibold text-gray-900">
{item.label}
</span> </span>
<div className="flex items-center gap-2"> <span className="text-sm font-semibold text-gray-500">{item.value}</span>
<input <ChevronRightIcon className="h-4 w-4 text-gray-300" />
type="number" </Link>
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> </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 ? ( {isPlatformAdmin ? (
<div className="rounded-lg border border-gray-100 bg-gray-50 px-4 py-3 text-sm text-gray-600"> <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{" "} Als Admin kannst du die{" "}
@ -746,7 +659,7 @@ export default function EinstellungenPage() {
</p> </p>
) : ( ) : (
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
Empfohlen, besonders für Admin-Accounts. Empfohlen, besonders fuer Admin-Accounts.
</p> </p>
)} )}
</div> </div>
@ -867,7 +780,7 @@ export default function EinstellungenPage() {
/> />
{passwordMismatch ? ( {passwordMismatch ? (
<p className="text-xs text-red-600"> <p className="text-xs text-red-600">
Die neuen Passwörter stimmen nicht überein. Die neuen Passwoerter stimmen nicht ueberein.
</p> </p>
) : null} ) : null}
</SectionCard> </SectionCard>

View file

@ -2,7 +2,6 @@
import { import {
BeakerIcon, BeakerIcon,
BoltIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon, ClockIcon,
@ -69,7 +68,6 @@ const fallbackGoals = {
fat: 67, fat: 67,
protein: 150, protein: 150,
fastingHours: 12, fastingHours: 12,
sportAddsCalories: true,
}; };
function toDateKey(date: Date) { function toDateKey(date: Date) {
@ -305,9 +303,7 @@ export default function DashboardPage() {
function invalidateDay() { function invalidateDay() {
utils.health.overview.invalidate({ date: selectedDate }); utils.health.overview.invalidate({ date: selectedDate });
// Alle statsSummary-Varianten invalidieren, damit Wochenbudget-Seite und utils.health.statsSummary.invalidate({ referenceDate: todayKey });
// Statistiken konsistent bleiben (unterschiedliche referenceDate-Caches).
utils.health.statsSummary.invalidate();
} }
const addMealMutation = trpc.health.addMealLog.useMutation({ onSuccess: invalidateDay }); const addMealMutation = trpc.health.addMealLog.useMutation({ onSuccess: invalidateDay });
@ -330,7 +326,6 @@ export default function DashboardPage() {
fat: Number(overviewQuery.data.goals.fatG) || fallbackGoals.fat, fat: Number(overviewQuery.data.goals.fatG) || fallbackGoals.fat,
protein: Number(overviewQuery.data.goals.proteinG) || fallbackGoals.protein, protein: Number(overviewQuery.data.goals.proteinG) || fallbackGoals.protein,
fastingHours: overviewQuery.data.goals.fastingHours, fastingHours: overviewQuery.data.goals.fastingHours,
sportAddsCalories: overviewQuery.data.goals.sportAddsCalories ?? true,
} }
: fallbackGoals; : fallbackGoals;
@ -373,25 +368,9 @@ export default function DashboardPage() {
return { calories, protein, drinkMl, carbs, fat }; return { calories, protein, drinkMl, carbs, fat };
}, [day.drinks, day.foods]); }, [day.drinks, day.foods]);
// Tageswerte aus dem Wochenplan (enthaelt die per Wochenbudget verteilten Ziele const remainingCalories = Math.max(0, goals.calories - totals.calories);
// und einen evtl. eingeplanten Mehrbedarf fuer diesen Tag).
const weekDay = weeklyBudget?.days.find((entry) => entry.date === selectedDate);
const dayActivityCalories = weekDay?.activityCalories ?? 0;
const dailySportBonus = goals.sportAddsCalories ? dayActivityCalories : 0;
// Basis-Tagesziel ist der verteilte Plan-Wert dieses Tages (z. B. 1.740 nach
// Umverteilung oder 2.800 an einem Blocker-Tag), sonst das allgemeine Tagesziel.
const baseDailyGoal = weekDay?.plannedCalories ?? goals.calories;
const dayExtraCalories = weekDay?.extraCalories ?? 0;
const dailyCalorieGoal = baseDailyGoal + 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)); 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() { function addFoodEntry() {
if (!isEditable || !foodForm.name.trim()) return; if (!isEditable || !foodForm.name.trim()) return;
addMealMutation.mutate({ addMealMutation.mutate({
@ -499,47 +478,13 @@ export default function DashboardPage() {
<div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4"> <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.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-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"> <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="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 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> </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> </section>
) : null} ) : null}
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800"> <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 className="flex items-start justify-between gap-4">
<div> <div>
<div className="flex items-center gap-2 text-gray-400"> <div className="flex items-center gap-2 text-gray-400">
@ -549,12 +494,6 @@ export default function DashboardPage() {
<p className="mt-5 text-5xl font-black text-gray-950 dark:text-neutral-100"> <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> {remainingCalories.toLocaleString("de-DE")} <span className="text-3xl text-gray-300">kcal</span>
</p> </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> </div>
<button <button
type="button" type="button"
@ -586,16 +525,7 @@ export default function DashboardPage() {
</div> </div>
<div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400"> <div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400">
<span> <span>Ziel: {goals.calories.toLocaleString("de-DE")} kcal</span>
Ziel: {baseDailyGoal.toLocaleString("de-DE")} kcal
{dailySportBonus > 0
? ` + ${dailySportBonus.toLocaleString("de-DE")} Sport = ${dailyCalorieGoal.toLocaleString("de-DE")} kcal`
: ""}
{dayExtraCalories > 0 ? " · höherer Tag eingeplant" : ""}
{baseDailyGoal < goals.calories && dayExtraCalories === 0
? " · Ausgleich für höheren Tag"
: ""}
</span>
<EllipsisHorizontalIcon className="h-7 w-7" /> <EllipsisHorizontalIcon className="h-7 w-7" />
</div> </div>
</section> </section>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { ArrowPathIcon, CalendarDaysIcon, FireIcon, SparklesIcon } from "@heroicons/react/24/outline"; import { ArrowPathIcon, CalendarDaysIcon, FireIcon, SparklesIcon } from "@heroicons/react/24/outline";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { todayKey } from "@/lib/hellth-data"; import { todayKey } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
@ -15,63 +15,48 @@ function prognosis(balance: number) {
return { label: "leicht drüber - flexibel ausgleichen", color: "text-amber-700 bg-amber-50" }; return { label: "leicht drüber - flexibel ausgleichen", color: "text-amber-700 bg-amber-50" };
} }
type ExtraDraft = { extraCalories: string; note: string };
export default function WochenbudgetPage() { export default function WochenbudgetPage() {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const [selectedDate, setSelectedDate] = useState(todayKey()); const [selectedDate, setSelectedDate] = useState(todayKey());
const [extraDate, setExtraDate] = useState(todayKey());
const [extraCalories, setExtraCalories] = useState("800");
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
// Pro Tag eingeplanter Mehrbedarf ("Blocker"), bevor er verteilt wird.
const [drafts, setDrafts] = useState<Record<string, ExtraDraft>>({});
const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: selectedDate }); const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: selectedDate });
const summary = summaryQuery.data?.week ?? null; const summary = summaryQuery.data?.week ?? null;
const prediction = summary ? prognosis(summary.balance) : null; const prediction = summary ? prognosis(summary.balance) : null;
const upsertPlanMutation = trpc.health.upsertWeeklyCaloriePlan.useMutation({
const setExtrasMutation = trpc.health.setWeeklyExtras.useMutation({
onSuccess: async () => { onSuccess: async () => {
// Ohne Key-Filter invalidieren, damit Dashboard + Statistiken mitziehen. await utils.health.statsSummary.invalidate({ referenceDate: selectedDate });
await utils.health.statsSummary.invalidate();
await utils.health.overview.invalidate();
setSaved(true); setSaved(true);
window.setTimeout(() => setSaved(false), 1500); window.setTimeout(() => setSaved(false), 1500);
}, },
}); });
// Entwurf aus den gespeicherten Tageswerten vorbefüllen, sobald die Woche geladen ist. const dayByDate = useMemo(() => new Map((summary?.days ?? []).map((day) => [day.date, day])), [summary?.days]);
useEffect(() => {
if (!summary) return; function updatePlan(date: string, plannedCalories: number, note?: string) {
setDrafts((current) => { upsertPlanMutation.mutate({
const next: Record<string, ExtraDraft> = {}; date,
for (const day of summary.days) { plannedCalories: Math.max(800, Math.min(6000, Math.round(plannedCalories))),
next[day.date] = current[day.date] ?? { note: note ?? dayByDate.get(date)?.note ?? null,
extraCalories: day.extraCalories ? String(day.extraCalories) : "",
note: day.note ?? "",
};
}
return next;
}); });
}, [summary]);
const totalExtra = useMemo(
() => Object.values(drafts).reduce((sum, draft) => sum + (Number(draft.extraCalories) || 0), 0),
[drafts],
);
function updateDraft(date: string, patch: Partial<ExtraDraft>) {
setDrafts((current) => ({ ...current, [date]: { ...current[date], ...patch } as ExtraDraft }));
} }
function redistribute() { function applyRestaurantDistribution() {
if (!summary) return; if (!summary) return;
setExtrasMutation.mutate({ const extra = Math.max(0, Number(extraCalories) || 0);
weekStart: summary.weekStart, const days = summary.days;
days: summary.days.map((day) => ({ const targetDay = days.find((day) => day.date === extraDate);
date: day.date, if (!targetDay) return;
extraCalories: Math.max(0, Number(drafts[day.date]?.extraCalories) || 0),
note: drafts[day.date]?.note?.trim() || null, 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) { if (!summary || !prediction) {
@ -92,9 +77,7 @@ export default function WochenbudgetPage() {
<p className="text-sm font-black uppercase text-[#0F766E]">Flexible Planung</p> <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> <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"> <p className="mt-3 max-w-2xl text-sm font-semibold text-gray-400">
Trag pro Tag ein, wenn du mehr brauchst (z. B. Familienfeier +800). Mit Ein höherer Tag ist kein Problem. Hellth Hub verteilt den Ausgleich freundlich auf die Woche.
„kcal neu verteilen" hebt Hellth Hub diese Tage an und gleicht den Rest der Woche
gleichmäßig aus. Dein Wochenziel bleibt gleich.
</p> </p>
</div> </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" /> <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" />
@ -115,7 +98,7 @@ export default function WochenbudgetPage() {
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100"> <div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<SparklesIcon className="h-8 w-8 text-emerald-500" /> <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="mt-3 text-3xl font-black text-gray-950">+{fmt(summary.activityCalories)}</p>
<p className="text-sm font-bold text-gray-400">Sportbonus (Woche)</p> <p className="text-sm font-bold text-gray-400">Sportbonus</p>
</div> </div>
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100"> <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> <div className={`inline-flex rounded-full px-3 py-1 text-xs font-black ${prediction.color}`}>{prediction.label}</div>
@ -125,34 +108,13 @@ export default function WochenbudgetPage() {
</section> </section>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100"> <section 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">Tagesverteilung</h2>
<div>
<h2 className="text-xl font-black text-gray-950">Tagesverteilung</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">
{totalExtra > 0
? `${fmt(totalExtra)} kcal Mehrbedarf eingeplant – wird beim Verteilen auf die übrigen Tage ausgeglichen.`
: "Trag bei einzelnen Tagen einen Mehrbedarf ein und verteile anschließend."}
</p>
</div>
<button
type="button"
onClick={redistribute}
disabled={setExtrasMutation.isPending}
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-gray-950 px-5 py-3 text-sm font-black text-white disabled:opacity-40"
>
<ArrowPathIcon className="h-5 w-5" />
{setExtrasMutation.isPending ? "Verteile…" : "kcal neu verteilen"}
</button>
</div>
<div className="mt-5 grid gap-3"> <div className="mt-5 grid gap-3">
{summary.days.map((day) => { {summary.days.map((day) => {
const date = new Date(`${day.date}T12:00:00`); const date = new Date(`${day.date}T12:00:00`);
const over = day.eatenCalories > day.plannedCalories + day.activityCalories; const over = day.eatenCalories > day.plannedCalories + day.activityCalories;
const draft = drafts[day.date] ?? { extraCalories: "", note: "" };
const isBlocker = (Number(draft.extraCalories) || 0) > 0;
return ( return (
<div key={day.date} className={`grid gap-3 rounded-3xl p-4 md:grid-cols-[120px_1fr_150px_150px_110px] md:items-center ${isBlocker ? "bg-amber-50 ring-1 ring-amber-200" : "bg-gray-50"}`}> <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> <div>
<p className="text-lg font-black text-gray-950">{date.toLocaleDateString("de-DE", { weekday: "short" })}</p> <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> <p className="text-xs font-bold text-gray-400">{date.toLocaleDateString("de-DE")}</p>
@ -164,25 +126,14 @@ export default function WochenbudgetPage() {
<p className="mt-2 text-sm font-bold text-gray-400"> <p className="mt-2 text-sm font-bold text-gray-400">
{fmt(day.eatenCalories)} / {fmt(day.plannedCalories)} kcal {fmt(day.eatenCalories)} / {fmt(day.plannedCalories)} kcal
{day.activityCalories ? ` + ${fmt(day.activityCalories)} Sport` : ""} {day.activityCalories ? ` + ${fmt(day.activityCalories)} Sport` : ""}
{day.note ? ` - ${day.note}` : ""}
</p> </p>
</div> </div>
<label className="flex items-center gap-2">
<span className="text-sm font-black text-gray-400">+</span>
<input
type="number"
inputMode="numeric"
min={0}
value={draft.extraCalories}
onChange={(event) => updateDraft(day.date, { extraCalories: event.target.value })}
placeholder="kcal"
className="w-full rounded-2xl border border-gray-200 px-3 py-3 text-sm font-bold"
/>
</label>
<input <input
value={draft.note} type="number"
onChange={(event) => updateDraft(day.date, { note: event.target.value })} value={day.plannedCalories}
placeholder="Anlass" onChange={(event) => updatePlan(day.date, Number(event.target.value) || summary.dailyGoal, day.note)}
className="rounded-2xl border border-gray-200 px-3 py-3 text-sm font-bold" 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"}`}> <p className={`text-sm font-black ${day.balance >= 0 ? "text-emerald-700" : "text-amber-700"}`}>
{day.balance >= 0 ? "+" : ""}{fmt(day.balance)} kcal {day.balance >= 0 ? "+" : ""}{fmt(day.balance)} kcal
@ -193,6 +144,21 @@ export default function WochenbudgetPage() {
</div> </div>
</section> </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} {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> </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

@ -50,6 +50,7 @@ const navGroups: NavGroup[] = [
{ href: "/wearables", label: "Wearables", icon: DevicePhoneMobileIcon }, { href: "/wearables", label: "Wearables", icon: DevicePhoneMobileIcon },
{ href: "/hellfireclub", label: "Hellfireclub", icon: FireIcon }, { href: "/hellfireclub", label: "Hellfireclub", icon: FireIcon },
{ href: "/gewicht", label: "Gewicht", icon: ScaleIcon }, { href: "/gewicht", label: "Gewicht", icon: ScaleIcon },
{ href: "/ziele", label: "Ziele", icon: ChartBarIcon },
], ],
}, },
{ {

View file

@ -58,7 +58,6 @@ export function buildWeeklyBudgetSummary(data: HellthData, dateKey = todayKey())
return { return {
date, date,
plannedCalories, plannedCalories,
extraCalories: planByDate.get(date)?.extraCalories ?? 0,
eatenCalories, eatenCalories,
activityCalories, activityCalories,
balance: plannedCalories + activityCalories - eatenCalories, balance: plannedCalories + activityCalories - eatenCalories,

View file

@ -172,7 +172,6 @@ export type ActivityLog = {
export type WeeklyCaloriePlan = { export type WeeklyCaloriePlan = {
date: string; date: string;
plannedCalories: number; plannedCalories: number;
extraCalories?: number;
note?: string; note?: string;
}; };
@ -255,7 +254,6 @@ export type HellthData = {
export type WeeklyBudgetDay = { export type WeeklyBudgetDay = {
date: string; date: string;
plannedCalories: number; plannedCalories: number;
extraCalories: number;
eatenCalories: number; eatenCalories: number;
activityCalories: number; activityCalories: number;
balance: number; balance: number;

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.

View file

@ -1,6 +1,4 @@
# Hellth Hub - Projektreferenz # Hellth Hub Projektinfo
Ausführliches Nachschlagewerk. Die immer geltenden Regeln und der Schnellstart stehen in [`CLAUDE.md`](../CLAUDE.md) im Repo-Root.
## Kurzüberblick ## Kurzüberblick
@ -8,6 +6,18 @@ Hellth Hub ist eine mobile-optimierte Web-App für persönliches Health-, Ernäh
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. 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 ## Tech Stack
- Monorepo mit `pnpm` - Monorepo mit `pnpm`
@ -17,7 +27,44 @@ Das Projekt wurde technisch aus einem kopierten Naturfreunde-Hub-Grundgerüst en
- Docker: Postgres und pgAdmin, plus vorhandener App-Container - Docker: Postgres und pgAdmin, plus vorhandener App-Container
- Auth: Better Auth mit Login, 2FA, Passkeys - Auth: Better Auth mit Login, 2FA, Passkeys
## Seeds ## 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 ```powershell
pnpm --filter @hellth/api seed:health pnpm --filter @hellth/api seed:health
@ -67,7 +114,7 @@ Der Demo-Recipe-Seed erzeugt 50 öffentliche Testrezepte mit IDs `demo-recipe-01
Wearables/Fitbit/Health-Connect-Vorbereitung. Wearables/Fitbit/Health-Connect-Vorbereitung.
- `apps/admin/app/(authed)/einstellungen/page.tsx` - `apps/admin/app/(authed)/einstellungen/page.tsx`
User-Profil, Sicherheit, 2FA, Passkeys, Rezept-/Ernährungspräferenzen. Aufgeteilt in Tabs (`_components/settings-tabs.tsx`, `section-card.tsx`, `status-banner.tsx`), Tab-Auswahl über `?tab=`-Query. User-Profil, Sicherheit, 2FA, Passkeys, Rezept-/Ernährungspräferenzen.
- `apps/admin/app/(authed)/einstellungen/punkte/page.tsx` - `apps/admin/app/(authed)/einstellungen/punkte/page.tsx`
Globale Admin-Einstellung für Hellfireclub-/Aktivitätspunkte. Globale Admin-Einstellung für Hellfireclub-/Aktivitätspunkte.
@ -174,9 +221,18 @@ Vorschläge berücksichtigen vorrangig User-Profil:
### Admin Activity Feed ### Admin Activity Feed
Pfad: `/aktivitaetsfeed` Pfad:
Zeigt aktuell Rezeptvorschläge und Marktänderungsanträge. Marktänderungen können direkt übernommen oder abgelehnt werden. ```text
/aktivitaetsfeed
```
Zeigt aktuell:
- Rezeptvorschläge
- Marktänderungsanträge
Marktänderungen können direkt übernommen oder abgelehnt werden.
### Hellfireclub ### Hellfireclub
@ -198,6 +254,18 @@ Zeigt aktuell Rezeptvorschläge und Marktänderungsanträge. Marktänderungen k
- Fitbit-Aktivitätskatalog ist vorhanden. - Fitbit-Aktivitätskatalog ist vorhanden.
- Manuelle Aktivitäten sind möglich. - 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 ## Tests und Checks
Admin-Tests: Admin-Tests:
@ -224,12 +292,12 @@ Admin-Build:
$env:NEXT_PUBLIC_API_URL='http://localhost:3002'; pnpm --filter admin build $env:NEXT_PUBLIC_API_URL='http://localhost:3002'; pnpm --filter admin build
``` ```
Zuletzt erfolgreich (Stand 2026-05-29): Zuletzt erfolgreich:
- API-Tests: 38/38 - API-Tests: 28/28
- Admin-Tests: 10/10 - Admin-Tests: 10/10
- Typecheck: grün (alle Pakete) - API-Typecheck: grün
- Lint: 0 Errors (react-hooks-Warnings = bestehendes Muster) - Admin-Build: grün
## Lokale Dev-Logs ## Lokale Dev-Logs
@ -242,7 +310,25 @@ Wenn die Prozesse im Hintergrund gestartet wurden, liegen Logs hier:
.logs/admin-dev.err.log .logs/admin-dev.err.log
``` ```
## Repo-Konsistenz und Altlasten ## 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: Das Hellth-Hub-Monorepo liegt direkt im Repo-Root. Aktuell relevant sind:
@ -271,4 +357,14 @@ Diese Altlast ist noch da und sollte **nicht angepackt werden**, weil sie die DB
- `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). - `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).
Keine Naturfreunde-Funktionalität wiederbeleben. Wenn eine Altlast auftaucht: höchstens kurz erwähnen, dann im aktuellen Hellth-Hub-Code weiterarbeiten. Cleanup nur nach ausdrücklicher Benutzerfreigabe. ### 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.

View file

@ -1,7 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { appRouter } from "./index"; import { appRouter } from "./index";
import { distributeWeeklyExtras } from "./routers/health/_shared";
type Call = { op: string; payload?: unknown }; type Call = { op: string; payload?: unknown };
@ -672,185 +671,3 @@ 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,
);
});
test("distributeWeeklyExtras haelt das Wochenbudget konstant", async () => {
const daily = 2000;
// So +800 (Familienfeier), Mi +500 (Trinken), Rest ohne Extra.
const planned = distributeWeeklyExtras(
{
"2026-05-25": 0,
"2026-05-26": 0,
"2026-05-27": 500,
"2026-05-28": 0,
"2026-05-29": 0,
"2026-05-30": 0,
"2026-05-31": 800,
},
daily,
);
// Blocker-Tage = Tagesziel + Extra
assert.equal(planned["2026-05-27"], 2500);
assert.equal(planned["2026-05-31"], 2800);
// 1300 Extra auf 5 Tage ohne Extra: ceil(1300/5)=260 Abzug pro Tag
assert.equal(planned["2026-05-25"], 1740);
// Wochenbudget bleibt im Rahmen des Ausgleichs erhalten (kleine Ceil-Rundung erlaubt).
const total = Object.values(planned).reduce((sum, value) => sum + value, 0);
assert.ok(total <= daily * 7, `Summe ${total} darf das Wochenziel ${daily * 7} nicht ueberschreiten`);
assert.ok(total >= daily * 7 - 7, "Summe darf nur minimal (Rundung) unter dem Wochenziel liegen");
});
test("distributeWeeklyExtras unterschreitet den Floor nicht", async () => {
// Sehr grosser Blocker an einem Tag, der die anderen rechnerisch unter 1200 druecken wuerde.
const planned = distributeWeeklyExtras(
{
"2026-05-25": 0,
"2026-05-26": 0,
"2026-05-31": 4000,
},
2000,
1200,
);
assert.equal(planned["2026-05-31"], 6000);
// 4000/2 = 2000 Abzug -> 2000-2000=0, aber Floor greift bei 1200
assert.equal(planned["2026-05-25"], 1200);
assert.equal(planned["2026-05-26"], 1200);
});
test("health.setWeeklyExtras verteilt und speichert plannedCalories pro Tag", async () => {
const { db, calls } = createHealthDbMock();
const caller = appRouter.createCaller(createContext({ db }) as never);
const result = await caller.health.setWeeklyExtras({
weekStart: "2026-05-25",
days: [
{ date: "2026-05-25", extraCalories: 0 },
{ date: "2026-05-26", extraCalories: 0 },
{ date: "2026-05-27", extraCalories: 0 },
{ date: "2026-05-28", extraCalories: 0 },
{ date: "2026-05-29", extraCalories: 0 },
{ date: "2026-05-30", extraCalories: 0 },
{ date: "2026-05-31", extraCalories: 700, note: "Familienfeier" },
],
});
assert.deepEqual(result, { ok: true });
// Mock-Goals liefern calories=2000. Blocker-Tag bekommt 2700, andere 2000-ceil(700/6)=1883.
const insertCalls = calls.filter((call) => call.op === "tx.insert.values");
const sunday = insertCalls.find((call) => (call.payload as { date?: string }).date === "2026-05-31")?.payload as {
plannedCalories?: number;
extraCalories?: number;
note?: string | null;
};
assert.equal(sunday.plannedCalories, 2700);
assert.equal(sunday.extraCalories, 700);
assert.equal(sunday.note, "Familienfeier");
const monday = insertCalls.find((call) => (call.payload as { date?: string }).date === "2026-05-25")?.payload as {
plannedCalories?: number;
extraCalories?: number;
};
assert.equal(monday.plannedCalories, 2000 - Math.ceil(700 / 6));
assert.equal(monday.extraCalories, 0);
});

View file

@ -249,36 +249,6 @@ export function numberString(value: number | string | null | undefined, fallback
return String(value); return String(value);
} }
/**
* Verteilt eingeplante Mehrkalorien ("Blocker", z. B. Familienfeier) gleichmaessig
* auf die uebrigen Wochentage, sodass das Wochenbudget (dailyGoal * 7) exakt erhalten
* bleibt. Tage mit Extra erhalten dailyGoal + extra; Tage ohne Extra werden anteilig
* reduziert, aber nie unter floor (Schutz vor zu niedrigen Tageszielen).
*
* Gibt fuer jeden uebergebenen Tag den errechneten plannedCalories-Wert zurueck.
*/
export function distributeWeeklyExtras(
extrasByDate: Record<string, number>,
dailyGoal: number,
floor = 1200,
): Record<string, number> {
const dates = Object.keys(extrasByDate);
const totalExtra = dates.reduce((sum, date) => sum + Math.max(0, extrasByDate[date] ?? 0), 0);
const daysWithoutExtra = dates.filter((date) => (extrasByDate[date] ?? 0) <= 0);
const reductionPerDay = daysWithoutExtra.length
? Math.ceil(totalExtra / daysWithoutExtra.length)
: 0;
const result: Record<string, number> = {};
for (const date of dates) {
const extra = Math.max(0, extrasByDate[date] ?? 0);
result[date] = extra > 0
? dailyGoal + extra
: Math.max(floor, dailyGoal - reductionPerDay);
}
return result;
}
export function startOfWeek(dateKey: string) { export function startOfWeek(dateKey: string) {
const date = new Date(`${dateKey}T12:00:00`); const date = new Date(`${dateKey}T12:00:00`);
const day = date.getDay() === 0 ? 7 : date.getDay(); const day = date.getDay() === 0 ? 7 : date.getDay();

View file

@ -309,7 +309,6 @@ export const communityRouter = router({
return { return {
date, date,
plannedCalories, plannedCalories,
extraCalories: weeklyPlanByDate.get(date)?.extraCalories ?? 0,
eatenCalories, eatenCalories,
activityCalories, activityCalories,
balance: plannedCalories + activityCalories - eatenCalories, balance: plannedCalories + activityCalories - eatenCalories,

View file

@ -120,8 +120,8 @@ export const dashboardRouter = router({
.values({ .values({
userId, userId,
...input, ...input,
heightCm: input.heightCm ? numberString(input.heightCm) : null, heightCm: numberString(input.heightCm, 0),
currentWeightKg: input.currentWeightKg ? numberString(input.currentWeightKg) : null, currentWeightKg: numberString(input.currentWeightKg, 0),
targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null, targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null,
completedAt: input.completed ? new Date() : null, completedAt: input.completed ? new Date() : null,
updatedAt: new Date(), updatedAt: new Date(),
@ -130,8 +130,8 @@ export const dashboardRouter = router({
target: schema.healthProfiles.userId, target: schema.healthProfiles.userId,
set: { set: {
...input, ...input,
heightCm: input.heightCm ? numberString(input.heightCm) : null, heightCm: numberString(input.heightCm, 0),
currentWeightKg: input.currentWeightKg ? numberString(input.currentWeightKg) : null, currentWeightKg: numberString(input.currentWeightKg, 0),
targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null, targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null,
completedAt: input.completed ? new Date() : null, completedAt: input.completed ? new Date() : null,
updatedAt: new Date(), updatedAt: new Date(),

View file

@ -7,7 +7,6 @@ import {
addDays, addDays,
auditHealthMutation, auditHealthMutation,
dateKeySchema, dateKeySchema,
distributeWeeklyExtras,
id, id,
mealTypeSchema, mealTypeSchema,
numberString, numberString,
@ -309,60 +308,6 @@ export const trackingRouter = router({
return { ok: true }; return { ok: true };
}), }),
// Setzt fuer eine ganze Woche die eingeplanten Mehrkalorien ("Blocker") pro Tag und
// verteilt den Ausgleich serverseitig, sodass das Wochenbudget (dailyGoal * 7) erhalten
// bleibt. Ersetzt das fehleranfaellige Verteilen einzelner Tage im Frontend.
setWeeklyExtras: protectedProcedure
.input(z.object({
weekStart: dateKeySchema,
days: z.array(z.object({
date: dateKeySchema,
extraCalories: z.number().int().min(0).max(4000),
note: z.string().max(500).optional().nullable(),
})).min(1).max(7),
}))
.mutation(async ({ ctx, input }) => {
const goals = await ctx.db.query.healthGoals.findFirst({
where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id),
});
const dailyGoal = goals?.calories ?? 2000;
const extrasByDate: Record<string, number> = {};
for (const day of input.days) extrasByDate[day.date] = day.extraCalories;
const noteByDate = new Map(input.days.map((day) => [day.date, day.note || null]));
const plannedByDate = distributeWeeklyExtras(extrasByDate, dailyGoal);
await ctx.db.transaction(async (tx) => {
for (const day of input.days) {
await tx
.insert(schema.weeklyCaloriePlans)
.values({
userId: ctx.user.id,
date: day.date,
plannedCalories: plannedByDate[day.date]!,
extraCalories: day.extraCalories,
note: noteByDate.get(day.date) ?? null,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [schema.weeklyCaloriePlans.userId, schema.weeklyCaloriePlans.date],
set: {
plannedCalories: plannedByDate[day.date]!,
extraCalories: day.extraCalories,
note: noteByDate.get(day.date) ?? null,
updatedAt: new Date(),
},
});
}
});
auditHealthMutation(ctx, "weekly-calorie-plan.set-extras", {
weekStart: input.weekStart,
totalExtra: Object.values(extrasByDate).reduce((sum, value) => sum + value, 0),
});
return { ok: true };
}),
addActivityLog: protectedProcedure addActivityLog: protectedProcedure
.input(z.object({ .input(z.object({
date: dateKeySchema, date: dateKeySchema,

View file

@ -1 +0,0 @@
ALTER TABLE "weekly_calorie_plans" ADD COLUMN "extraCalories" integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -127,13 +127,6 @@
"when": 1779962400000, "when": 1779962400000,
"tag": "0017_store_change_requests", "tag": "0017_store_change_requests",
"breakpoints": true "breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1780046007288,
"tag": "0018_outgoing_betty_ross",
"breakpoints": true
} }
] ]
} }

View file

@ -64,9 +64,6 @@ export const weeklyCaloriePlans = pgTable(
userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }), userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
date: date("date").notNull(), date: date("date").notNull(),
plannedCalories: integer("plannedCalories").notNull(), plannedCalories: integer("plannedCalories").notNull(),
// Vom Nutzer eingeplanter Mehrbedarf an diesem Tag (z. B. Familienfeier).
// plannedCalories wird daraus + Wochenausgleich abgeleitet.
extraCalories: integer("extraCalories").notNull().default(0),
note: text("note"), note: text("note"),
updatedAt: timestamp("updatedAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(),
}, },