Compare commits
4 commits
e42a8109bf
...
70f59cfd40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70f59cfd40 | ||
|
|
e264765876 | ||
|
|
e54a2ac223 | ||
|
|
36b92c07b7 |
23 changed files with 5395 additions and 401 deletions
|
|
@ -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 `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.
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -12,6 +12,9 @@ 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/
|
||||||
|
|
|
||||||
39
CLAUDE.md
39
CLAUDE.md
|
|
@ -1,11 +1,42 @@
|
||||||
# Claude Working Guidelines - Hellth Hub
|
# Claude Working Guidelines - Hellth Hub
|
||||||
|
|
||||||
Hellth Hub ist eine mobile-first Health- und Ernährungstracking-App für den Home-Lab-Betrieb.
|
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`).
|
||||||
|
|
||||||
Wichtig:
|
**Ausführliche Projektreferenz** (Dateilandkarte, Features, Altlasten): [`docs/projektreferenz.md`](docs/projektreferenz.md).
|
||||||
|
|
||||||
- Umlaute immer korrekt schreiben.
|
## Grundregeln
|
||||||
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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: "Persoenliche Daten und Kontakt" },
|
{ id: "profil", label: "Profil", description: "Persönliche Daten und Kontakt" },
|
||||||
{ id: "ernaehrung", label: "Ernaehrung", description: "Rezept- und Mahlzeiten-Praeferenzen" },
|
{ id: "ernaehrung", label: "Ernährung", description: "Rezept- und Mahlzeiten-Präferenzen" },
|
||||||
{ 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" },
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ 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";
|
||||||
|
|
@ -37,10 +36,51 @@ 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("");
|
||||||
|
|
@ -57,7 +97,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 verfuegbar");
|
if (!ctx) throw new Error("Canvas nicht verfügbar");
|
||||||
|
|
||||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||||
bitmap.close();
|
bitmap.close();
|
||||||
|
|
@ -67,7 +107,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 gross.");
|
throw new Error("Profilbild ist nach Komprimierung noch zu groß.");
|
||||||
}
|
}
|
||||||
return dataUrl;
|
return dataUrl;
|
||||||
}
|
}
|
||||||
|
|
@ -88,11 +128,9 @@ 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("");
|
||||||
|
|
@ -113,6 +151,7 @@ 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";
|
||||||
|
|
||||||
|
|
@ -137,11 +176,6 @@ 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([
|
||||||
|
|
@ -175,7 +209,19 @@ 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("Essenspraeferenzen gespeichert.");
|
setStatusMessage("Essenspräferenzen 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) => {
|
||||||
|
|
@ -200,6 +246,21 @@ 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)
|
||||||
|
|
@ -211,19 +272,27 @@ 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: profile?.heightCm == null ? null : Number(profile.heightCm),
|
heightCm: positiveOrNull(profile?.heightCm),
|
||||||
currentWeightKg:
|
currentWeightKg: positiveOrNull(profile?.currentWeightKg),
|
||||||
profile?.currentWeightKg == null ? null : Number(profile.currentWeightKg),
|
targetWeightKg: positiveOrNull(profile?.targetWeightKg),
|
||||||
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,
|
||||||
mealsPerDay: Number(mealsPerDay) || 3,
|
// An die Zod-Grenzen klemmen, damit eine zu grosse Eingabe nicht die
|
||||||
cooksPerDay: Number(cooksPerDay) || 1,
|
// ganze Mutation abweist (mealsPerDay 1-8, cooksPerDay 1-4, Prep 0-7).
|
||||||
preferredMealPrepDays: Number(preferredMealPrepDays) || 0,
|
mealsPerDay: clamp(Number(mealsPerDay) || 3, 1, 8),
|
||||||
|
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",
|
||||||
|
|
@ -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 isBusy = updateMyProfile.isPending || changeMyPassword.isPending;
|
||||||
const passwordMismatch =
|
const passwordMismatch =
|
||||||
confirmNewPassword.length > 0 && newPassword !== confirmNewPassword;
|
confirmNewPassword.length > 0 && newPassword !== confirmNewPassword;
|
||||||
|
|
@ -289,11 +373,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 auswaehlen.");
|
setErrorMessage("Bitte eine Bilddatei auswählen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
setErrorMessage("Profilbild ist zu gross. Maximal 5 MB.");
|
setErrorMessage("Profilbild ist zu groß. Maximal 5 MB.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -333,7 +417,7 @@ export default function EinstellungenPage() {
|
||||||
return (
|
return (
|
||||||
<DataPageLayout
|
<DataPageLayout
|
||||||
title="Einstellungen"
|
title="Einstellungen"
|
||||||
description="Profil, Sicherheit und persoenliche Vorlieben"
|
description="Profil, Sicherheit und persönliche Vorlieben"
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SettingsTabs active={active} onChange={setActive} />
|
<SettingsTabs active={active} onChange={setActive} />
|
||||||
|
|
@ -349,7 +433,7 @@ export default function EinstellungenPage() {
|
||||||
{active === "profil" ? (
|
{active === "profil" ? (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Mein Profil"
|
title="Mein Profil"
|
||||||
description="Persoenliche Daten und Kontakt. E-Mail-Aenderung erfolgt ueber das Konto."
|
description="Persönliche Daten und Kontakt. E-Mail-Änderung erfolgt über das Konto."
|
||||||
footer={
|
footer={
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
|
|
@ -447,12 +531,12 @@ export default function EinstellungenPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className={labelClass}>Strasse und Hausnummer</label>
|
<label className={labelClass}>Straße 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="Strasse und Hausnummer"
|
placeholder="Straße und Hausnummer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -480,7 +564,7 @@ export default function EinstellungenPage() {
|
||||||
|
|
||||||
{active === "ernaehrung" ? (
|
{active === "ernaehrung" ? (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Rezept- und Mahlzeiten-Praeferenzen"
|
title="Rezept- und Mahlzeiten-Präferenzen"
|
||||||
description="Beeinflusst Vorschlaege und automatische Wochenplanung."
|
description="Beeinflusst Vorschlaege und automatische Wochenplanung."
|
||||||
footer={
|
footer={
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
@ -490,13 +574,13 @@ export default function EinstellungenPage() {
|
||||||
disabled={updateHealthProfile.isPending}
|
disabled={updateHealthProfile.isPending}
|
||||||
className={buttonPrimary}
|
className={buttonPrimary}
|
||||||
>
|
>
|
||||||
Praeferenzen speichern
|
Präferenzen speichern
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className={labelClass}>Ernaehrung</p>
|
<p className={labelClass}>Ernährung</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);
|
||||||
|
|
@ -528,7 +612,7 @@ export default function EinstellungenPage() {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={labelClass}>Kochvorgaenge/Tag</span>
|
<span className={labelClass}>Kochvorgänge/Tag</span>
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
|
@ -568,57 +652,60 @@ export default function EinstellungenPage() {
|
||||||
{active === "ziele" ? (
|
{active === "ziele" ? (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Ziele"
|
title="Ziele"
|
||||||
description="Kalorien, Makros, Wasser, Fasten und Gewicht werden unter /ziele detailliert eingestellt."
|
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}
|
||||||
>
|
>
|
||||||
<div className="divide-y divide-gray-100">
|
{upsertGoals.isPending ? "Speichere…" : "Ziele speichern"}
|
||||||
{[
|
</button>
|
||||||
{
|
</div>
|
||||||
icon: FireIcon,
|
}
|
||||||
label: "Kalorien",
|
|
||||||
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}`} />
|
<div className="space-y-4">
|
||||||
<span className="flex-1 text-sm font-semibold text-gray-900">
|
{goalsFields.map((field) => (
|
||||||
{item.label}
|
<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>
|
||||||
<span className="text-sm font-semibold text-gray-500">{item.value}</span>
|
<div className="flex items-center gap-2">
|
||||||
<ChevronRightIcon className="h-4 w-4 text-gray-300" />
|
<input
|
||||||
</Link>
|
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>
|
</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{" "}
|
||||||
|
|
@ -659,7 +746,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 fuer Admin-Accounts.
|
Empfohlen, besonders für Admin-Accounts.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -780,7 +867,7 @@ export default function EinstellungenPage() {
|
||||||
/>
|
/>
|
||||||
{passwordMismatch ? (
|
{passwordMismatch ? (
|
||||||
<p className="text-xs text-red-600">
|
<p className="text-xs text-red-600">
|
||||||
Die neuen Passwoerter stimmen nicht ueberein.
|
Die neuen Passwörter stimmen nicht überein.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BeakerIcon,
|
BeakerIcon,
|
||||||
|
BoltIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
|
|
@ -68,6 +69,7 @@ const fallbackGoals = {
|
||||||
fat: 67,
|
fat: 67,
|
||||||
protein: 150,
|
protein: 150,
|
||||||
fastingHours: 12,
|
fastingHours: 12,
|
||||||
|
sportAddsCalories: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function toDateKey(date: Date) {
|
function toDateKey(date: Date) {
|
||||||
|
|
@ -303,7 +305,9 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
function invalidateDay() {
|
function invalidateDay() {
|
||||||
utils.health.overview.invalidate({ date: selectedDate });
|
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 });
|
const addMealMutation = trpc.health.addMealLog.useMutation({ onSuccess: invalidateDay });
|
||||||
|
|
@ -326,6 +330,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -368,9 +373,25 @@ export default function DashboardPage() {
|
||||||
return { calories, protein, drinkMl, carbs, fat };
|
return { calories, protein, drinkMl, carbs, fat };
|
||||||
}, [day.drinks, day.foods]);
|
}, [day.drinks, day.foods]);
|
||||||
|
|
||||||
const remainingCalories = Math.max(0, goals.calories - totals.calories);
|
// Tageswerte aus dem Wochenplan (enthaelt die per Wochenbudget verteilten Ziele
|
||||||
|
// 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({
|
||||||
|
|
@ -478,13 +499,47 @@ 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"><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 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">
|
||||||
|
|
@ -494,6 +549,12 @@ 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"
|
||||||
|
|
@ -525,7 +586,16 @@ 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>Ziel: {goals.calories.toLocaleString("de-DE")} kcal</span>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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 { useMemo, useState } from "react";
|
import { useEffect, 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,48 +15,63 @@ 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 () => {
|
||||||
await utils.health.statsSummary.invalidate({ referenceDate: selectedDate });
|
// Ohne Key-Filter invalidieren, damit Dashboard + Statistiken mitziehen.
|
||||||
|
await utils.health.statsSummary.invalidate();
|
||||||
|
await utils.health.overview.invalidate();
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
window.setTimeout(() => setSaved(false), 1500);
|
window.setTimeout(() => setSaved(false), 1500);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dayByDate = useMemo(() => new Map((summary?.days ?? []).map((day) => [day.date, day])), [summary?.days]);
|
// Entwurf aus den gespeicherten Tageswerten vorbefüllen, sobald die Woche geladen ist.
|
||||||
|
useEffect(() => {
|
||||||
function updatePlan(date: string, plannedCalories: number, note?: string) {
|
|
||||||
upsertPlanMutation.mutate({
|
|
||||||
date,
|
|
||||||
plannedCalories: Math.max(800, Math.min(6000, Math.round(plannedCalories))),
|
|
||||||
note: note ?? dayByDate.get(date)?.note ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRestaurantDistribution() {
|
|
||||||
if (!summary) return;
|
if (!summary) return;
|
||||||
const extra = Math.max(0, Number(extraCalories) || 0);
|
setDrafts((current) => {
|
||||||
const days = summary.days;
|
const next: Record<string, ExtraDraft> = {};
|
||||||
const targetDay = days.find((day) => day.date === extraDate);
|
for (const day of summary.days) {
|
||||||
if (!targetDay) return;
|
next[day.date] = current[day.date] ?? {
|
||||||
|
extraCalories: day.extraCalories ? String(day.extraCalories) : "",
|
||||||
const otherDays = days.filter((day) => day.date !== extraDate);
|
note: day.note ?? "",
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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() {
|
||||||
|
if (!summary) return;
|
||||||
|
setExtrasMutation.mutate({
|
||||||
|
weekStart: summary.weekStart,
|
||||||
|
days: summary.days.map((day) => ({
|
||||||
|
date: day.date,
|
||||||
|
extraCalories: Math.max(0, Number(drafts[day.date]?.extraCalories) || 0),
|
||||||
|
note: drafts[day.date]?.note?.trim() || null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!summary || !prediction) {
|
if (!summary || !prediction) {
|
||||||
|
|
@ -77,7 +92,9 @@ 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">
|
||||||
Ein höherer Tag ist kein Problem. Hellth Hub verteilt den Ausgleich freundlich auf die Woche.
|
Trag pro Tag ein, wenn du mehr brauchst (z. B. Familienfeier +800). Mit
|
||||||
|
„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" />
|
||||||
|
|
@ -98,7 +115,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</p>
|
<p className="text-sm font-bold text-gray-400">Sportbonus (Woche)</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>
|
||||||
|
|
@ -108,13 +125,34 @@ 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">
|
||||||
|
<div>
|
||||||
<h2 className="text-xl font-black text-gray-950">Tagesverteilung</h2>
|
<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 bg-gray-50 p-4 md:grid-cols-[130px_1fr_160px_120px] md:items-center">
|
<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>
|
<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>
|
||||||
|
|
@ -126,14 +164,25 @@ 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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={day.plannedCalories}
|
inputMode="numeric"
|
||||||
onChange={(event) => updatePlan(day.date, Number(event.target.value) || summary.dailyGoal, day.note)}
|
min={0}
|
||||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold"
|
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
|
||||||
|
value={draft.note}
|
||||||
|
onChange={(event) => updateDraft(day.date, { note: event.target.value })}
|
||||||
|
placeholder="Anlass"
|
||||||
|
className="rounded-2xl border border-gray-200 px-3 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
|
||||||
|
|
@ -144,21 +193,6 @@ 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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: "/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 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ export type ActivityLog = {
|
||||||
export type WeeklyCaloriePlan = {
|
export type WeeklyCaloriePlan = {
|
||||||
date: string;
|
date: string;
|
||||||
plannedCalories: number;
|
plannedCalories: number;
|
||||||
|
extraCalories?: number;
|
||||||
note?: string;
|
note?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -254,6 +255,7 @@ 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
6
apps/admin/next-env.d.ts
vendored
|
|
@ -1,6 +0,0 @@
|
||||||
/// <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.
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
# Hellth Hub Projektinfo
|
# Hellth Hub - Projektreferenz
|
||||||
|
|
||||||
|
Ausführliches Nachschlagewerk. Die immer geltenden Regeln und der Schnellstart stehen in [`CLAUDE.md`](../CLAUDE.md) im Repo-Root.
|
||||||
|
|
||||||
## Kurzüberblick
|
## Kurzüberblick
|
||||||
|
|
||||||
|
|
@ -6,18 +8,6 @@ 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`
|
||||||
|
|
@ -27,44 +17,7 @@ Das Monorepo liegt direkt im Repo-Root (`apps/`, `packages/`, `docker/` …). De
|
||||||
- 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
|
||||||
|
|
||||||
## Wichtige Ports
|
## Seeds
|
||||||
|
|
||||||
- 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
|
||||||
|
|
@ -114,7 +67,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.
|
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.
|
||||||
|
|
||||||
- `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.
|
||||||
|
|
@ -221,18 +174,9 @@ Vorschläge berücksichtigen vorrangig User-Profil:
|
||||||
|
|
||||||
### Admin Activity Feed
|
### Admin Activity Feed
|
||||||
|
|
||||||
Pfad:
|
Pfad: `/aktivitaetsfeed`
|
||||||
|
|
||||||
```text
|
Zeigt aktuell Rezeptvorschläge und Marktänderungsanträge. Marktänderungen können direkt übernommen oder abgelehnt werden.
|
||||||
/aktivitaetsfeed
|
|
||||||
```
|
|
||||||
|
|
||||||
Zeigt aktuell:
|
|
||||||
|
|
||||||
- Rezeptvorschläge
|
|
||||||
- Marktänderungsanträge
|
|
||||||
|
|
||||||
Marktänderungen können direkt übernommen oder abgelehnt werden.
|
|
||||||
|
|
||||||
### Hellfireclub
|
### Hellfireclub
|
||||||
|
|
||||||
|
|
@ -254,18 +198,6 @@ Marktänderungen können direkt übernommen oder abgelehnt werden.
|
||||||
- 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:
|
||||||
|
|
@ -292,12 +224,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:
|
Zuletzt erfolgreich (Stand 2026-05-29):
|
||||||
|
|
||||||
- API-Tests: 28/28
|
- API-Tests: 38/38
|
||||||
- Admin-Tests: 10/10
|
- Admin-Tests: 10/10
|
||||||
- API-Typecheck: grün
|
- Typecheck: grün (alle Pakete)
|
||||||
- Admin-Build: grün
|
- Lint: 0 Errors (react-hooks-Warnings = bestehendes Muster)
|
||||||
|
|
||||||
## Lokale Dev-Logs
|
## Lokale Dev-Logs
|
||||||
|
|
||||||
|
|
@ -310,25 +242,7 @@ Wenn die Prozesse im Hintergrund gestartet wurden, liegen Logs hier:
|
||||||
.logs/admin-dev.err.log
|
.logs/admin-dev.err.log
|
||||||
```
|
```
|
||||||
|
|
||||||
## Wichtige Arbeitsregeln
|
## Repo-Konsistenz und Altlasten
|
||||||
|
|
||||||
- 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:
|
||||||
|
|
||||||
|
|
@ -357,14 +271,4 @@ 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).
|
||||||
|
|
||||||
### Arbeitsregel für neue Aufgaben
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
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 };
|
||||||
|
|
||||||
|
|
@ -671,3 +672,185 @@ 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,36 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,8 @@ export const dashboardRouter = router({
|
||||||
.values({
|
.values({
|
||||||
userId,
|
userId,
|
||||||
...input,
|
...input,
|
||||||
heightCm: numberString(input.heightCm, 0),
|
heightCm: input.heightCm ? numberString(input.heightCm) : null,
|
||||||
currentWeightKg: numberString(input.currentWeightKg, 0),
|
currentWeightKg: input.currentWeightKg ? numberString(input.currentWeightKg) : null,
|
||||||
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: numberString(input.heightCm, 0),
|
heightCm: input.heightCm ? numberString(input.heightCm) : null,
|
||||||
currentWeightKg: numberString(input.currentWeightKg, 0),
|
currentWeightKg: input.currentWeightKg ? numberString(input.currentWeightKg) : null,
|
||||||
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(),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
addDays,
|
addDays,
|
||||||
auditHealthMutation,
|
auditHealthMutation,
|
||||||
dateKeySchema,
|
dateKeySchema,
|
||||||
|
distributeWeeklyExtras,
|
||||||
id,
|
id,
|
||||||
mealTypeSchema,
|
mealTypeSchema,
|
||||||
numberString,
|
numberString,
|
||||||
|
|
@ -308,6 +309,60 @@ 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,
|
||||||
|
|
|
||||||
1
packages/db/drizzle/0018_outgoing_betty_ross.sql
Normal file
1
packages/db/drizzle/0018_outgoing_betty_ross.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "weekly_calorie_plans" ADD COLUMN "extraCalories" integer DEFAULT 0 NOT NULL;
|
||||||
4624
packages/db/drizzle/meta/0018_snapshot.json
Normal file
4624
packages/db/drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -127,6 +127,13 @@
|
||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +64,9 @@ 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(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue