feat: initial commit – Mitai Jinkendo v9a
Some checks are pending
Deploy to Raspberry Pi / deploy (push) Waiting to run
Build Test / build-frontend (push) Waiting to run
Build Test / lint-backend (push) Waiting to run

This commit is contained in:
Lars Stommer 2026-03-16 13:35:11 +01:00
commit 89b6c0b072
51 changed files with 9458 additions and 0 deletions

29
.env.example Normal file
View File

@ -0,0 +1,29 @@
# ── Datenbank ──────────────────────────────────────────────────
# v9 (PostgreSQL):
DB_PASSWORD=sicheres_passwort_hier
# v8 (SQLite, legacy):
# DATA_DIR=/app/data
# ── KI ─────────────────────────────────────────────────────────
# OpenRouter (empfohlen):
OPENROUTER_API_KEY=sk-or-...
OPENROUTER_MODEL=anthropic/claude-sonnet-4
# Oder direkt Anthropic:
# ANTHROPIC_API_KEY=sk-ant-...
# ── E-Mail (SMTP) ───────────────────────────────────────────────
SMTP_HOST=smtp.strato.de
SMTP_PORT=587
SMTP_USER=lars@stommer.de
SMTP_PASS=dein_passwort
SMTP_FROM=lars@stommer.de
# ── App ─────────────────────────────────────────────────────────
APP_URL=https://mitai.jinkendo.de
ALLOWED_ORIGINS=https://mitai.jinkendo.de
# ── Pfade ───────────────────────────────────────────────────────
PHOTOS_DIR=/app/photos
ENVIRONMENT=production

View File

@ -0,0 +1,38 @@
name: Deploy to Raspberry Pi
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
cd /home/lars/docker/mitai
echo "=== Pulling latest code ==="
git pull origin main
echo "=== Rebuilding containers ==="
docker compose build --no-cache
echo "=== Restarting ==="
docker compose up -d
echo "=== Health check ==="
sleep 5
docker ps | grep jinkendo
curl -sf http://localhost:8002/api/auth/status | python3 -c "import sys,json; d=json.load(sys.stdin); print('✓ API healthy')" || echo "⚠ API check failed"
echo "=== Deploy complete ==="

33
.gitea/workflows/test.yml Normal file
View File

@ -0,0 +1,33 @@
name: Build Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install & Build
working-directory: ./frontend
run: |
npm install
npm run build
- name: Check build output
run: ls -la frontend/dist/
lint-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Check syntax
run: python3 -m py_compile backend/main.py && echo "✓ Backend syntax OK"

60
.gitignore vendored Normal file
View File

@ -0,0 +1,60 @@
# Environment
.env
.env.local
.env.production
# Dependencies
node_modules/
__pycache__/
*.pyc
*.pyo
.Python
*.egg-info/
dist/
build/
.venv/
venv/
# Build output
frontend/dist/
# Data (NEVER commit database or user data)
*.db
*.sqlite
*.sqlite3
data/
photos/
uploads/
# Logs
*.log
logs/
# IDE
.vscode/settings.json
.idea/
*.swp
# OS
.DS_Store
Thumbs.db
# Docker overrides
docker-compose.override.yml
# SSL certificates (never commit)
nginx/ssl/
nginx/certbot/
*.pem
*.key
*.crt
*.csr
# Pytest
.pytest_cache/
.coverage
coverage/
# Temp
tmp/
*.tmp

154
CLAUDE.md Normal file
View File

@ -0,0 +1,154 @@
# Mitai Jinkendo Entwickler-Kontext für Claude Code
## Projekt-Übersicht
**Mitai Jinkendo** ist eine selbst-gehostete PWA für Körper-Tracking (Gewicht, Körperfett, Umfänge, Ernährung, Aktivität) mit KI-Auswertung. Teil der **Jinkendo**-App-Familie (人拳道 Der menschliche Weg der Kampfkunst).
**Produktfamilie:** body · fight · guard · train · mind (alle unter jinkendo.de)
## Tech-Stack
| Komponente | Technologie | Version |
|-----------|-------------|---------|
| Frontend | React 18 + Vite + PWA | Node 20 |
| Backend | FastAPI (Python) | Python 3.12 |
| Datenbank | PostgreSQL | 16 (Ziel: v9) / SQLite (aktuell: v8) |
| Container | Docker + Docker Compose | - |
| Webserver | nginx (Reverse Proxy + HTTPS) | Alpine |
| Auth | Token-basiert (eigene Impl.) | - |
| KI | OpenRouter API (claude-sonnet-4) | - |
## Ports
| Service | Intern | Extern (Dev) |
|---------|--------|-------------|
| Frontend | 80 (nginx) | 3002 |
| Backend | 8000 (uvicorn) | 8002 |
| PostgreSQL | 5432 | nicht exponiert |
## Verzeichnisstruktur
```
mitai-jinkendo/
├── backend/
│ ├── main.py # FastAPI App, alle Endpoints
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── App.jsx # Root, Auth-Gates, Navigation
│ │ ├── app.css # Globale Styles, CSS-Variablen
│ │ ├── context/
│ │ │ ├── AuthContext.jsx # Session, Login, Logout
│ │ │ └── ProfileContext.jsx # Aktives Profil
│ │ ├── pages/ # Alle Screens
│ │ ├── utils/
│ │ │ ├── api.js # Alle API-Calls (injiziert Token + ProfileId)
│ │ │ ├── calc.js # Körperfett-Formeln
│ │ │ ├── interpret.js # Regelbasierte Auswertung
│ │ │ ├── Markdown.jsx # Eigener MD-Renderer
│ │ │ └── guideData.js # Messanleitungen
│ │ └── main.jsx
│ ├── public/ # Icons (Jinkendo Ensō-Logo)
│ ├── index.html
│ ├── vite.config.js
│ └── Dockerfile
├── nginx/
│ └── nginx.conf
├── docker-compose.yml # Produktion
├── docker-compose.dev.yml # Entwicklung (Hot-Reload)
├── .env.example
└── CLAUDE.md # Diese Datei
```
## Aktuelle Version: v8
### Was implementiert ist:
- ✅ Multi-User mit PIN/Passwort-Auth + Token-Sessions
- ✅ Admin/User Rollen, KI-Limits, Export-Berechtigungen
- ✅ Gewicht, Umfänge, Caliper (4 Formeln), Ernährung, Aktivität
- ✅ FDDB CSV-Import (Ernährung), Apple Health CSV-Import (Aktivität)
- ✅ KI-Analyse: 6 Einzel-Prompts + 3-stufige Pipeline (parallel)
- ✅ Konfigurierbare Prompts mit Template-Variablen
- ✅ Verlauf mit 5 Tabs + Zeitraumfilter + KI pro Sektion
- ✅ Dashboard mit Kennzahlen, Zielfortschritt, Combo-Chart
- ✅ Assistent-Modus (Schritt-für-Schritt Messung)
- ✅ PWA (iPhone Home Screen), Jinkendo-Icon
- ✅ E-Mail (SMTP) für Password-Recovery
- ✅ Admin-Panel: User verwalten, KI-Limits, E-Mail-Test
### Was in v9 kommt:
- 🔲 PostgreSQL Migration (aktuell: SQLite)
- 🔲 Auth-Middleware auf ALLE Endpoints
- 🔲 bcrypt statt SHA256
- 🔲 Rate Limiting
- 🔲 CORS auf Domain beschränken
- 🔲 Selbst-Registrierung mit E-Mail-Bestätigung
- 🔲 Freemium Tier-System (free/basic/premium/selfhosted)
- 🔲 Login via E-Mail statt Profil-Liste
- 🔲 nginx + Let's Encrypt
## Datenbank-Schema (SQLite, v8)
### Wichtige Tabellen:
- `profiles` Nutzer mit Auth (role, pin_hash, auth_type, ai_enabled, export_enabled)
- `sessions` Auth-Tokens mit Ablaufdatum
- `weight_log` Gewichtseinträge (profile_id, date, weight)
- `circumference_log` 8 Umfangspunkte
- `caliper_log` Hautfaltenmessung, 4 Methoden
- `nutrition_log` Kalorien + Makros (aus FDDB-CSV)
- `activity_log` Training (aus Apple Health oder manuell)
- `ai_insights` KI-Auswertungen (scope = prompt-slug)
- `ai_prompts` Konfigurierbare Prompts mit Templates
- `ai_usage` KI-Calls pro Tag pro Profil
## Auth-Flow (aktuell v8)
```
Login-Screen → Profil-Liste → PIN/Passwort → Token im localStorage
Token → X-Auth-Token Header → Backend require_auth()
Profile-Id → aus Session (nicht aus Header!)
```
## API-Konventionen
- Alle Endpoints: `/api/...`
- Auth-Header: `X-Auth-Token: <token>`
- Profile-Header: `X-Profile-Id: <uuid>` (nur wo noch nicht migriert)
- Responses: immer JSON
- Fehler: `{"detail": "Fehlermeldung"}`
## Umgebungsvariablen (.env)
```
OPENROUTER_API_KEY= # KI-Calls
OPENROUTER_MODEL=anthropic/claude-sonnet-4
ANTHROPIC_API_KEY= # Alternative zu OpenRouter
SMTP_HOST= # E-Mail
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
APP_URL= # Für Links in E-Mails
DATA_DIR=/app/data # SQLite-Pfad (v8)
PHOTOS_DIR=/app/photos
# v9 (PostgreSQL):
DATABASE_URL=postgresql://jinkendo:password@db/jinkendo
DB_PASSWORD=
```
## Deployment (aktuell)
```bash
# Heimserver (Raspberry Pi 5, lars@raspberrypi5)
cd /home/lars/docker/bodytrack
docker compose build --no-cache [frontend|backend]
docker compose up -d
docker logs mitai-api --tail 30
```
## Wichtige Hinweise für Claude Code
1. **Ports immer 3002/8002** nie ändern
2. **npm install** (nicht npm ci) kein package-lock.json vorhanden
3. **SQLite safe_alters** neue Spalten immer via _safe_alters() hinzufügen
4. **Pipeline-Prompts** haben slug-Prefix `pipeline_` nie als Einzelanalyse zeigen
5. **dayjs.week()** braucht Plugin stattdessen native JS Wochenberechnung
6. **useNavigate()** nur in React-Komponenten (Großbuchstabe), nicht in Helper-Functions
7. **Bar fill=function** in Recharts nicht unterstützt nur statische Farben
## Code-Style
- React: Functional Components, Hooks
- CSS: Inline-Styles + globale CSS-Variablen (var(--accent), var(--text1), etc.)
- API-Calls: immer über `api.js` (injiziert Token automatisch)
- Kein TypeScript (bewusst, für Einfachheit)
- Python: keine Type-Hints Pflicht, aber bei neuen Funktionen erwünscht

81
README.md Normal file
View File

@ -0,0 +1,81 @@
# BodyTrack
Körpervermessung & Körperfett Tracker selbst gehostet, PWA-fähig.
## Features
- Umfänge & Caliper-Messungen (4 Methoden) mit Verlauf
- Abgeleitete Werte: WHR, WHtR, FFMI, Magermasse
- Verlaufsdiagramme (Gewicht, KF%, Taille, …)
- KI-Interpretationen via Claude (Anthropic)
- Fortschrittsfotos mit Galerie
- PDF & CSV Export
- PWA installierbar auf iPhone-Homescreen
- Alle Daten lokal auf deinem Server (SQLite)
## Schnellstart
### 1. Voraussetzungen
```bash
# Docker & Docker Compose installieren (Ubuntu)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Neu einloggen
```
### 2. Projekt klonen / kopieren
```bash
mkdir ~/bodytrack && cd ~/bodytrack
# Dateien hierher kopieren
```
### 3. API Key setzen
```bash
cp .env.example .env
nano .env
# ANTHROPIC_API_KEY=sk-ant-... eintragen
```
### 4. Starten
```bash
docker compose up -d
```
App läuft auf: **http://DEINE-IP:3000**
### 5. iPhone Als App installieren
1. Safari öffnen → `http://DEINE-IP:3000`
2. Teilen-Button (□↑) → „Zum Home-Bildschirm"
3. BodyTrack erscheint als App-Icon
### 6. Von außen erreichbar (optional)
```bash
# Tailscale (einfachste Lösung VPN zu deinem MiniPC)
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Dann: http://TAILSCALE-IP:3000
```
## Updates
```bash
docker compose pull
docker compose up -d --build
```
## Backup
```bash
# Datenbank & Fotos sichern
docker run --rm -v bodytrack-data:/data -v bodytrack-photos:/photos \
-v $(pwd):/backup alpine \
tar czf /backup/bodytrack_backup_$(date +%Y%m%d).tar.gz /data /photos
```
## Konfiguration
| Variable | Beschreibung | Standard |
|---|---|---|
| `ANTHROPIC_API_KEY` | Claude API Key (für KI-Analyse) | |
## Ports
| Port | Dienst |
|---|---|
| 3000 | Frontend (Nginx) |
| 8000 | Backend API (intern) |

216
SETUP.md Normal file
View File

@ -0,0 +1,216 @@
# Mitai Jinkendo Entwicklungs-Setup
## 1. Gitea Repository anlegen
### Auf dem Pi (Gitea):
```bash
# Im Gitea Web-UI:
# → New Repository: "mitai-jinkendo"
# → Visibility: Private
# → Initialize: Nein (wir pushen bestehenden Code)
```
### Auf deinem Entwicklungsrechner:
```bash
# Einmalig: SSH-Key für Gitea hinterlegen
ssh-keygen -t ed25519 -C "lars-dev"
# Public Key in Gitea: Settings → SSH Keys → Add Key
# Repo klonen / initialisieren
cd /pfad/zum/projekt
git init
git remote add origin git@raspberrypi5:lars/mitai-jinkendo.git
# Ersten Commit
git add .
git commit -m "feat: initial commit v8 Mitai Jinkendo"
git push -u origin main
```
---
## 2. Gitea Actions einrichten
### Runner auf dem Pi installieren:
```bash
# Gitea Runner installieren (führt die Workflows aus)
wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-arm64
chmod +x act_runner-linux-arm64
sudo mv act_runner-linux-arm64 /usr/local/bin/act_runner
# Runner registrieren
# Token findest du in Gitea: Site Admin → Runners → New Runner
act_runner register \
--instance http://localhost:3000 \
--token DEIN_TOKEN \
--name "pi-runner" \
--labels ubuntu-latest
# Als Service einrichten
sudo nano /etc/systemd/system/gitea-runner.service
```
`/etc/systemd/system/gitea-runner.service`:
```ini
[Unit]
Description=Gitea Act Runner
After=network.target
[Service]
User=lars
WorkingDirectory=/home/lars/gitea-runner
ExecStart=/usr/local/bin/act_runner daemon
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now gitea-runner
```
### Deploy Secrets in Gitea hinterlegen:
```
Gitea → Repository → Settings → Secrets:
DEPLOY_HOST = 127.0.0.1 (oder raspberrypi5.local)
DEPLOY_USER = lars
DEPLOY_SSH_KEY = (privater SSH-Key, der Zugriff auf den Pi hat)
```
---
## 3. Deployment-Verzeichnis auf dem Pi vorbereiten
```bash
# Einmalig auf dem Pi:
cd /home/lars/docker
git clone git@localhost:lars/mitai-jinkendo.git bodytrack
cd bodytrack
# .env anlegen (NICHT committen!)
cp .env.example .env
nano .env # Werte ausfüllen
# Ersten Start
docker compose up -d
```
---
## 4. Claude Code einrichten
### VS Code Extension:
```
1. VS Code öffnen
2. Extensions → "Claude Code" suchen und installieren
3. Oder direkt: code --install-extension anthropic.claude-code
```
### Projekt öffnen:
```bash
cd /pfad/zu/mitai-jinkendo
code .
```
Claude Code liest automatisch `CLAUDE.md` und kennt damit:
- Den gesamten Tech-Stack
- Was schon implementiert ist
- Was als nächstes kommt (v9)
- Wichtige Hinweise (Ports, bekannte Fallstricke)
### Typischer Workflow:
```
1. Feature im Chat mit Claude besprechen
2. Claude Code schreibt/ändert Dateien direkt
3. git diff prüfen
4. git commit + push
5. Gitea Action deployed automatisch auf den Pi
```
---
## 5. DynDNS + Let's Encrypt Setup
### Fritz!Box MyFRITZ! einrichten:
```
1. Fritz!Box UI → Internet → MyFRITZ!-Konto
2. MyFRITZ!-Adresse notieren: z.B. "xxxx.myfritz.net"
3. Portfreigabe einrichten:
- Port 80 → Raspberry Pi (für HTTP/Let's Encrypt)
- Port 443 → Raspberry Pi (für HTTPS)
```
### Strato DNS einrichten:
```
Strato Kundenbereich → Domains → mitai.jinkendo.de → DNS
CNAME body → xxxx.myfritz.net
```
*Alternativ: A-Record + DynDNS-Update-Script*
### nginx auf dem Pi installieren:
```bash
sudo apt install nginx
sudo cp nginx/nginx.conf /etc/nginx/sites-available/jinkendo
sudo ln -s /etc/nginx/sites-available/jinkendo /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### Let's Encrypt Zertifikat holen:
```bash
# Sicherstellen dass Port 80 von außen erreichbar ist!
# Dann:
sudo bash nginx/certbot-setup.sh
```
---
## 6. Entwicklungs-Workflow (täglich)
```bash
# Feature entwickeln
git checkout -b feature/v9-auth
# ... Code schreiben ...
git add .
git commit -m "feat(auth): add bcrypt password hashing"
# Lokal testen
docker compose -f docker-compose.yml \
-f docker-compose.dev.yml up
# Nach Test: merge + deploy
git checkout main
git merge feature/v9-auth
git push # → Gitea Action startet automatisch → Pi wird updated
```
---
## 7. Nächste Schritte (v9 Roadmap)
### v9a Security (Prio: 🔴 Kritisch):
- [ ] bcrypt für Passwörter
- [ ] Auth-Middleware auf alle Endpoints
- [ ] CORS einschränken
- [ ] Rate Limiting
- [ ] Login via E-Mail statt Profil-Liste
### v9a Infrastruktur:
- [ ] PostgreSQL Migration
- [ ] nginx + Let's Encrypt live
- [ ] Gitea Actions deployed
### v9b Freemium:
- [ ] Tier-System (free/basic/premium)
- [ ] Selbst-Registrierung + E-Mail-Bestätigung
- [ ] Trial (14 Tage)
- [ ] Einladungslinks für Beta
- [ ] Admin-Panel: User + Tiers verwalten
### v9c Connectoren (Vorbereitung):
- [ ] OAuth2-Grundgerüst
- [ ] Strava Connector
- [ ] Withings Connector (Waage)

7
backend/Dockerfile Normal file
View File

@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/data /app/photos
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

2077
backend/main.py Normal file

File diff suppressed because it is too large Load Diff

9
backend/requirements.txt Normal file
View File

@ -0,0 +1,9 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
anthropic==0.26.0
python-multipart==0.0.9
Pillow==10.3.0
aiofiles==23.2.1
pydantic==2.7.1
bcrypt==4.1.3
slowapi==0.1.9

29
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,29 @@
# Development overrides - use with: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
backend:
build:
context: ./backend
target: dev
volumes:
- ./backend:/app # Hot-reload: mount source
- jinkendo-data:/app/data
- jinkendo-photos:/app/photos
environment:
- DATABASE_URL= # Empty = use SQLite fallback
- DATA_DIR=/app/data
- ENVIRONMENT=development
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
target: dev
volumes:
- ./frontend/src:/app/src # Hot-reload
ports:
- "3002:5173"
command: npm run dev -- --host
volumes:
jinkendo-data:
jinkendo-photos:

39
docker-compose.yml Normal file
View File

@ -0,0 +1,39 @@
services:
backend:
build: ./backend
container_name: mitai-api
restart: unless-stopped
ports:
- "8002:8000"
volumes:
- bodytrack_bodytrack-data:/app/data
- bodytrack_bodytrack-photos:/app/photos
environment:
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- SMTP_FROM=${SMTP_FROM}
- APP_URL=${APP_URL}
- DATA_DIR=/app/data
- PHOTOS_DIR=/app/photos
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- ENVIRONMENT=production
frontend:
build: ./frontend
container_name: mitai-ui
restart: unless-stopped
ports:
- "3002:80"
depends_on:
- backend
volumes:
bodytrack_bodytrack-data:
external: true
bodytrack_bodytrack-photos:
external: true

11
frontend/Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

19
frontend/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Mitai">
<link rel="apple-touch-icon" href="/icon-192.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<link rel="shortcut icon" href="/favicon.ico">
<title>Mitai Jinkendo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

16
frontend/nginx.conf Normal file
View File

@ -0,0 +1,16 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
client_max_body_size 20M;
}
location / {
try_files $uri $uri/ /index.html;
}
}

26
frontend/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "bodytrack",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"recharts": "^2.12.7",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"dayjs": "^1.11.11",
"lucide-react": "^0.383.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"vite": "^5.2.12",
"vite-plugin-pwa": "^0.20.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

135
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,135 @@
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom'
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings } from 'lucide-react'
import { ProfileProvider, useProfile } from './context/ProfileContext'
import { AuthProvider, useAuth } from './context/AuthContext'
import { setProfileId } from './utils/api'
import { Avatar } from './pages/ProfileSelect'
import SetupScreen from './pages/SetupScreen'
import { ResetPassword } from './pages/PasswordRecovery'
import LoginScreen from './pages/LoginScreen'
import Dashboard from './pages/Dashboard'
import CaptureHub from './pages/CaptureHub'
import WeightScreen from './pages/WeightScreen'
import CircumScreen from './pages/CircumScreen'
import CaliperScreen from './pages/CaliperScreen'
import MeasureWizard from './pages/MeasureWizard'
import History from './pages/History'
import NutritionPage from './pages/NutritionPage'
import ActivityPage from './pages/ActivityPage'
import Analysis from './pages/Analysis'
import SettingsPage from './pages/SettingsPage'
import GuidePage from './pages/GuidePage'
import './app.css'
function Nav() {
const links = [
{ to:'/', icon:<LayoutDashboard size={20}/>, label:'Übersicht' },
{ to:'/capture', icon:<PlusSquare size={20}/>, label:'Erfassen' },
{ to:'/history', icon:<TrendingUp size={20}/>, label:'Verlauf' },
{ to:'/analysis', icon:<BarChart2 size={20}/>, label:'Analyse' },
{ to:'/settings', icon:<Settings size={20}/>, label:'Einst.' },
]
return (
<nav className="bottom-nav">
{links.map(l=>(
<NavLink key={l.to} to={l.to} end={l.to==='/'} className={({isActive})=>'nav-item'+(isActive?' active':'')}>
{l.icon}<span>{l.label}</span>
</NavLink>
))}
</nav>
)
}
function AppShell() {
const { session, loading: authLoading, needsSetup } = useAuth()
const { activeProfile, loading: profileLoading } = useProfile()
const nav = useNavigate()
useEffect(()=>{
if (session?.profile_id) {
setProfileId(session.profile_id)
localStorage.setItem('mitai-jinkendo_active_profile', session.profile_id)
}
}, [session?.profile_id])
// Handle password reset link
const urlParams = new URLSearchParams(window.location.search)
const resetToken = urlParams.get('reset-password') || (window.location.pathname === '/reset-password' ? urlParams.get('token') : null)
if (resetToken) return (
<div style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center',
background:'var(--bg)',padding:24}}>
<div style={{width:'100%',maxWidth:360}}>
<div style={{textAlign:'center',marginBottom:20}}>
<div style={{fontSize:28,fontWeight:800,color:'var(--accent)'}}>Mitai Jinkendo</div>
</div>
<div className="card" style={{padding:20}}>
<ResetPassword token={resetToken} onDone={()=>window.location.href='/'}/>
</div>
</div>
</div>
)
// Auth loading
if (authLoading) return (
<div style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center'}}>
<div className="spinner" style={{width:32,height:32}}/>
</div>
)
// First run
if (needsSetup) return <SetupScreen/>
// Need to log in
if (!session) return <LoginScreen/>
// Profile loading
if (profileLoading) return (
<div style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center'}}>
<div className="spinner" style={{width:32,height:32}}/>
</div>
)
return (
<div className="app-shell">
<header className="app-header">
<span className="app-logo">Mitai Jinkendo</span>
<NavLink to="/settings" style={{textDecoration:'none'}}>
{activeProfile
? <Avatar profile={activeProfile} size={30}/>
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/>
}
</NavLink>
</header>
<main className="app-main">
<Routes>
<Route path="/" element={<Dashboard/>}/>
<Route path="/capture" element={<CaptureHub/>}/>
<Route path="/wizard" element={<MeasureWizard/>}/>
<Route path="/weight" element={<WeightScreen/>}/>
<Route path="/circum" element={<CircumScreen/>}/>
<Route path="/caliper" element={<CaliperScreen/>}/>
<Route path="/history" element={<History/>}/>
<Route path="/nutrition" element={<NutritionPage/>}/>
<Route path="/activity" element={<ActivityPage/>}/>
<Route path="/analysis" element={<Analysis/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/guide" element={<GuidePage/>}/>
</Routes>
</main>
<Nav/>
</div>
)
}
export default function App() {
return (
<AuthProvider>
<ProfileProvider>
<BrowserRouter>
<AppShell/>
</BrowserRouter>
</ProfileProvider>
</AuthProvider>
)
}

156
frontend/src/app.css Normal file
View File

@ -0,0 +1,156 @@
:root {
--bg: #f4f3ef;
--surface: #ffffff;
--surface2: #f9f8f5;
--border: rgba(0,0,0,0.09);
--border2: rgba(0,0,0,0.16);
--text1: #1c1b18;
--text2: #5a5955;
--text3: #9a9892;
--accent: #1D9E75;
--accent-light: #E1F5EE;
--accent-dark: #0a5c43;
--danger: #D85A30;
--warn: #EF9F27;
--nav-h: 64px;
--header-h: 52px;
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #181816; --surface: #222220; --surface2: #1e1e1c;
--border: rgba(255,255,255,0.08); --border2: rgba(255,255,255,0.14);
--text1: #eeecea; --text2: #aaa9a4; --text3: #686762;
--accent-light: #04342C; --accent-dark: #5DCAA5;
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { height: 100%; }
body { font-family: var(--font); background: var(--bg); color: var(--text1); -webkit-text-size-adjust: 100%; }
.app-shell { display: flex; flex-direction: column; height: 100%; max-width: 600px; margin: 0 auto; }
.app-header {
height: var(--header-h); display: flex; align-items: center; padding: 0 16px;
background: var(--surface); border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 10;
}
.app-logo { font-size: 18px; font-weight: 700; color: var(--accent); letter-spacing: -0.02em; }
.app-main { flex: 1; overflow-y: auto; padding: 16px 16px calc(var(--nav-h) + 16px); }
.bottom-nav {
position: fixed; bottom: 0; left: 50%; transform: translateX(-50%);
width: 100%; max-width: 600px;
height: var(--nav-h); display: flex; align-items: stretch;
background: var(--surface); border-top: 1px solid var(--border);
z-index: 20;
}
.nav-item {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 3px; color: var(--text3);
text-decoration: none; font-size: 10px; font-weight: 500;
transition: color 0.15s; padding-bottom: env(safe-area-inset-bottom, 0);
}
.nav-item.active { color: var(--accent); }
.nav-item svg { flex-shrink: 0; }
/* Cards */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
.card + .card { margin-top: 12px; }
.card-title { font-size: 13px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
/* Stats grid */
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px; }
.stat-val { font-size: 26px; font-weight: 700; color: var(--text1); line-height: 1; }
.stat-label { font-size: 12px; color: var(--text3); margin-top: 3px; }
.stat-delta { font-size: 12px; font-weight: 600; margin-top: 4px; }
.delta-pos { color: var(--accent); }
.delta-neg { color: var(--danger); }
/* Form */
.form-section { margin-bottom: 20px; }
.form-section-title {
font-size: 13px; font-weight: 600; color: var(--text3);
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
}
.form-row { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border); }
.form-row:last-child { border-bottom: none; }
.form-label { flex: 1; font-size: 14px; color: var(--text1); }
.form-sub { font-size: 11px; color: var(--text3); display: block; margin-top: 1px; }
.form-input {
width: 90px; padding: 7px 10px; text-align: right;
font-family: var(--font); font-size: 15px; font-weight: 500; color: var(--text1);
background: var(--surface2); border: 1.5px solid var(--border2);
border-radius: 8px; transition: border-color 0.15s;
}
.form-input:focus { outline: none; border-color: var(--accent); }
.form-unit { font-size: 12px; color: var(--text3); width: 24px; }
.form-select {
font-family: var(--font); font-size: 13px; color: var(--text1);
background: var(--surface2); border: 1.5px solid var(--border2);
border-radius: 8px; padding: 7px 10px; width: 100%;
}
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font); font-size: 14px; font-weight: 600;
padding: 10px 18px; border-radius: 10px; border: none; cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.btn:active { transform: scale(0.97); }
.btn-primary { background: var(--accent); color: white; }
.btn-secondary { background: var(--surface2); border: 1px solid var(--border2); color: var(--text2); }
.btn-danger { background: #FCEBEB; color: var(--danger); }
.btn-full { width: 100%; justify-content: center; }
.btn:disabled { opacity: 0.5; pointer-events: none; }
/* Badge */
.badge { display: inline-block; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px; }
/* AI content */
.ai-content { font-size: 14px; line-height: 1.7; color: var(--text2); white-space: pre-wrap; }
.ai-content strong { color: var(--text1); font-weight: 600; }
/* Photo grid */
.photo-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.photo-thumb { aspect-ratio: 1; border-radius: 8px; object-fit: cover; width: 100%; cursor: pointer; }
/* Tabs */
.tabs { display: flex; gap: 4px; background: var(--surface2); border-radius: 10px; padding: 3px; margin-bottom: 16px; }
.tab { flex: 1; text-align: center; padding: 7px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--text3); cursor: pointer; border: none; background: transparent; font-family: var(--font); }
.tab.active { background: var(--surface); color: var(--text1); box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
/* Section */
.section-gap { margin-bottom: 16px; }
.page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
.muted { color: var(--text3); font-size: 13px; }
.empty-state { text-align: center; padding: 48px 16px; color: var(--text3); }
.empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; }
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Additional vars */
:root {
--warn-bg: #FAEEDA;
--warn-text: #7a4b08;
}
@media (prefers-color-scheme: dark) {
:root {
--warn-bg: #3a2103;
--warn-text: #FAC775;
}
}
/* 6-item nav - smaller labels */
.nav-item span { font-size: 11px; }
/* 7-item nav scrollable */
.bottom-nav { overflow-x: auto; }
.nav-item span { font-size: 11px; }
.nav-item { min-width: 60px; }
/* Header with profile avatar */
.app-header { display:flex; align-items:center; justify-content:space-between; }
.app-header a { display:flex; }

View File

@ -0,0 +1,121 @@
import { createContext, useContext, useState, useEffect } from 'react'
const AuthContext = createContext(null)
const TOKEN_KEY = 'bodytrack_token'
const PROFILE_KEY = 'bodytrack_active_profile'
export function AuthProvider({ children }) {
const [session, setSession] = useState(null) // {token, profile_id, role}
const [loading, setLoading] = useState(true)
const [needsSetup, setNeedsSetup] = useState(false)
useEffect(() => {
checkStatus()
}, [])
const checkStatus = async () => {
try {
const r = await fetch('/api/auth/status')
const status = await r.json()
if (status.needs_setup) {
setNeedsSetup(true)
setLoading(false)
return
}
// Try existing token
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
const me = await fetch('/api/auth/me', {
headers: { 'X-Auth-Token': token }
})
if (me.ok) {
const profile = await me.json()
setSession({ token, profile_id: profile.id, role: profile.role, profile })
setLoading(false)
return
}
// Token expired
localStorage.removeItem(TOKEN_KEY)
}
} catch(e) {
console.error('Auth check failed', e)
}
setLoading(false)
}
const login = async (credentials) => {
// Support both new {email, pin} and legacy {profile_id, pin}
const body = typeof credentials === 'object' ? credentials : { profile_id: credentials }
const r = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!r.ok) {
const err = await r.json()
throw new Error(err.detail || 'Login fehlgeschlagen')
}
const data = await r.json()
localStorage.setItem(TOKEN_KEY, data.token)
localStorage.setItem(PROFILE_KEY, data.profile_id)
// Fetch full profile
const me = await fetch('/api/auth/me', { headers: { 'X-Auth-Token': data.token } })
const profile = await me.json()
setSession({ token: data.token, profile_id: data.profile_id, role: data.role, profile })
return data
}
const setup = async (formData) => {
const r = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
if (!r.ok) {
const err = await r.json()
throw new Error(err.detail || 'Setup fehlgeschlagen')
}
const data = await r.json()
localStorage.setItem(TOKEN_KEY, data.token)
localStorage.setItem(PROFILE_KEY, data.profile_id)
setNeedsSetup(false)
await checkStatus()
return data
}
const logout = async () => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
await fetch('/api/auth/logout', { method: 'POST', headers: { 'X-Auth-Token': token } })
}
localStorage.removeItem(TOKEN_KEY)
setSession(null)
}
const isAdmin = session?.role === 'admin'
const canUseAI = session?.profile?.ai_enabled !== 0
const canExport = session?.profile?.export_enabled !== 0
return (
<AuthContext.Provider value={{
session, loading, needsSetup,
login, setup, logout,
isAdmin, canUseAI, canExport,
token: session?.token,
profileId: session?.profile_id,
}}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
return useContext(AuthContext)
}
export function getToken() {
return localStorage.getItem(TOKEN_KEY)
}

View File

@ -0,0 +1,63 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { useAuth } from './AuthContext'
const ProfileContext = createContext(null)
export function ProfileProvider({ children }) {
const { session } = useAuth()
const [profiles, setProfiles] = useState([])
const [activeProfile, setActiveProfileState] = useState(null)
const [loading, setLoading] = useState(true)
const loadProfiles = async () => {
try {
const token = localStorage.getItem('bodytrack_token') || ''
const res = await fetch('/api/profiles', {
headers: { 'X-Auth-Token': token }
})
if (!res.ok) return []
return await res.json()
} catch(e) { return [] }
}
// Re-load whenever session changes (login/logout/switch)
useEffect(() => {
if (!session) {
setActiveProfileState(null)
setProfiles([])
setLoading(false)
return
}
setLoading(true)
loadProfiles().then(data => {
setProfiles(data)
// Always use the profile_id from the session token not localStorage
const match = data.find(p => p.id === session.profile_id)
setActiveProfileState(match || data[0] || null)
setLoading(false)
})
}, [session?.profile_id]) // re-runs when profile changes
const setActiveProfile = (profile) => {
setActiveProfileState(profile)
localStorage.setItem('bodytrack_active_profile', profile.id)
}
const refreshProfiles = () => loadProfiles().then(data => {
setProfiles(data)
if (activeProfile) {
const updated = data.find(p => p.id === activeProfile.id)
if (updated) setActiveProfileState(updated)
}
})
return (
<ProfileContext.Provider value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}>
{children}
</ProfileContext.Provider>
)
}
export function useProfile() {
return useContext(ProfileContext)
}

9
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@ -0,0 +1,315 @@
import { useState, useEffect, useRef } from 'react'
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
const ACTIVITY_TYPES = [
'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang',
'Innenräume Spaziergang','Laufen','Radfahren','Schwimmen',
'Cardio Dance','Geist & Körper','Sonstiges'
]
function empty() {
return {
date: dayjs().format('YYYY-MM-DD'),
activity_type: 'Traditionelles Krafttraining',
duration_min: '', kcal_active: '',
hr_avg: '', hr_max: '', rpe: '', notes: ''
}
}
// Import Panel
function ImportPanel({ onImported }) {
const fileRef = useRef()
const [status, setStatus] = useState(null)
const [error, setError] = useState(null)
const [dragging, setDragging] = useState(false)
const runImport = async (file) => {
setStatus('loading'); setError(null)
try {
const result = await api.importActivityCsv(file)
setStatus(result); onImported()
} catch(err) {
setError('Import fehlgeschlagen: ' + err.message); setStatus(null)
}
}
return (
<div className="card section-gap">
<div className="card-title">📥 Apple Health Import</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
<strong>Health Auto Export App</strong> Workouts exportieren CSV hier hochladen.<br/>
Nur die <em>Workouts-csv</em> Datei wird benötigt (nicht die Detaildateien).
</p>
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}}
onChange={e=>{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
<div
onDragOver={e=>{e.preventDefault();setDragging(true)}}
onDragLeave={()=>setDragging(false)}
onDrop={e=>{e.preventDefault();setDragging(false);const f=e.dataTransfer.files[0];if(f)runImport(f)}}
onClick={()=>fileRef.current.click()}
style={{border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,borderRadius:10,
padding:'20px 16px',textAlign:'center',background:dragging?'var(--accent-light)':'var(--surface2)',
cursor:'pointer',transition:'all 0.15s'}}>
<Upload size={24} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:6}}/>
<div style={{fontSize:13,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
{dragging?'Datei loslassen…':'CSV hierher ziehen oder tippen'}
</div>
</div>
{status==='loading' && (
<div style={{marginTop:8,display:'flex',gap:8,fontSize:13,color:'var(--text2)'}}>
<div className="spinner" style={{width:14,height:14}}/> Importiere
</div>
)}
{error && <div style={{marginTop:8,padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30'}}>{error}</div>}
{status && status!=='loading' && (
<div style={{marginTop:8,padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,fontSize:13,color:'var(--accent-dark)'}}>
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:2}}>
<CheckCircle size={14}/><strong>Import erfolgreich</strong>
</div>
<div>{status.inserted} Trainings importiert · {status.skipped} übersprungen</div>
</div>
)}
</div>
)
}
// Manual Entry
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Trainingsart</label>
<select className="form-select" value={form.activity_type} onChange={e=>set('activity_type',e.target.value)}>
{ACTIVITY_TYPES.map(t=><option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="form-row">
<label className="form-label">Dauer</label>
<input type="number" className="form-input" min={1} max={600} step={1}
placeholder="" value={form.duration_min||''} onChange={e=>set('duration_min',e.target.value)}/>
<span className="form-unit">Min</span>
</div>
<div className="form-row">
<label className="form-label">Kcal (aktiv)</label>
<input type="number" className="form-input" min={0} max={5000} step={1}
placeholder="" value={form.kcal_active||''} onChange={e=>set('kcal_active',e.target.value)}/>
<span className="form-unit">kcal</span>
</div>
<div className="form-row">
<label className="form-label">HF Ø</label>
<input type="number" className="form-input" min={40} max={220} step={1}
placeholder="" value={form.hr_avg||''} onChange={e=>set('hr_avg',e.target.value)}/>
<span className="form-unit">bpm</span>
</div>
<div className="form-row">
<label className="form-label">HF Max</label>
<input type="number" className="form-input" min={40} max={220} step={1}
placeholder="" value={form.hr_max||''} onChange={e=>set('hr_max',e.target.value)}/>
<span className="form-unit">bpm</span>
</div>
<div className="form-row">
<label className="form-label">Intensität</label>
<input type="number" className="form-input" min={1} max={10} step={1}
placeholder="110" value={form.rpe||''} onChange={e=>set('rpe',e.target.value)}/>
<span className="form-unit">RPE</span>
</div>
<div className="form-row">
<label className="form-label">Notiz</label>
<input type="text" className="form-input" placeholder="optional"
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/>
</div>
<div style={{display:'flex',gap:6,marginTop:8}}>
<button className="btn btn-primary" style={{flex:1}} onClick={onSave}>{saveLabel}</button>
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
</div>
</div>
)
}
// Main Page
export default function ActivityPage() {
const [entries, setEntries] = useState([])
const [stats, setStats] = useState(null)
const [tab, setTab] = useState('list')
const [form, setForm] = useState(empty())
const [editing, setEditing] = useState(null)
const [saved, setSaved] = useState(false)
const load = async () => {
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
setEntries(e); setStats(s)
}
useEffect(()=>{ load() },[])
const handleSave = async () => {
const payload = {...form}
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
payload.source = 'manual'
await api.createActivity(payload)
setSaved(true); await load()
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
}
const handleUpdate = async () => {
const payload = {...editing}
await api.updateActivity(editing.id, payload)
setEditing(null); await load()
}
const handleDelete = async (id) => {
if(!confirm('Training löschen?')) return
await api.deleteActivity(id); await load()
}
// Chart data: kcal per day (last 30 days)
const chartData = (() => {
const byDate = {}
entries.forEach(e=>{
byDate[e.date] = (byDate[e.date]||0) + (e.kcal_active||0)
})
return Object.entries(byDate).sort((a,b)=>a[0].localeCompare(b[0])).slice(-30).map(([date,kcal])=>({
date: dayjs(date).format('DD.MM'), kcal: Math.round(kcal)
}))
})()
const TYPE_COLORS = {
'Traditionelles Krafttraining':'#1D9E75','Matrial Arts':'#D85A30',
'Outdoor Spaziergang':'#378ADD','Innenräume Spaziergang':'#7F77DD',
'Laufen':'#EF9F27','Radfahren':'#D4537E','Sonstiges':'#888780'
}
return (
<div>
<h1 className="page-title">Aktivität</h1>
<div className="tabs" style={{overflowX:'auto',flexWrap:'nowrap'}}>
<button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button>
<button className={'tab'+(tab==='add'?' active':'')} onClick={()=>setTab('add')}>+ Manuell</button>
<button className={'tab'+(tab==='import'?' active':'')} onClick={()=>setTab('import')}>Import</button>
<button className={'tab'+(tab==='stats'?' active':'')} onClick={()=>setTab('stats')}>Statistik</button>
</div>
{/* Übersicht */}
{stats && stats.count>0 && (
<div className="card section-gap">
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
{[['Trainings',stats.count,'var(--text1)'],
['Kcal gesamt',Math.round(stats.total_kcal),'#EF9F27'],
['Stunden',Math.round(stats.total_min/60*10)/10,'#378ADD']].map(([l,v,c])=>(
<div key={l} style={{flex:1,minWidth:80,background:'var(--surface2)',borderRadius:8,padding:'8px 10px',textAlign:'center'}}>
<div style={{fontSize:18,fontWeight:700,color:c}}>{v}</div>
<div style={{fontSize:10,color:'var(--text3)'}}>{l}</div>
</div>
))}
</div>
</div>
)}
{tab==='import' && <ImportPanel onImported={load}/>}
{tab==='add' && (
<div className="card section-gap">
<div className="card-title">Training eintragen</div>
<EntryForm form={form} setForm={setForm}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
</div>
)}
{tab==='stats' && stats && (
<div>
{chartData.length>=2 && (
<div className="card section-gap">
<div className="card-title">Aktive Kalorien pro Tag</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={v=>[`${v} kcal`,'Aktiv']}/>
<Bar dataKey="kcal" fill="#EF9F27" radius={[3,3,0,0]}/>
</BarChart>
</ResponsiveContainer>
</div>
)}
<div className="card section-gap">
<div className="card-title">Nach Trainingsart</div>
{Object.entries(stats.by_type).sort((a,b)=>b[1].kcal-a[1].kcal).map(([type,data])=>(
<div key={type} style={{display:'flex',alignItems:'center',gap:10,padding:'6px 0',borderBottom:'1px solid var(--border)'}}>
<div style={{width:10,height:10,borderRadius:2,background:TYPE_COLORS[type]||'#888',flexShrink:0}}/>
<div style={{flex:1,fontSize:13}}>{type}</div>
<div style={{fontSize:12,color:'var(--text3)'}}>{data.count}× · {Math.round(data.min)} Min · {Math.round(data.kcal)} kcal</div>
</div>
))}
</div>
</div>
)}
{tab==='list' && (
<div>
{entries.length===0 && (
<div className="empty-state">
<h3>Keine Trainings</h3>
<p>Importiere deine Apple Health Daten oder trage manuell ein.</p>
</div>
)}
{entries.map(e=>{
const isEd = editing?.id===e.id
const color = TYPE_COLORS[e.activity_type]||'#888'
return (
<div key={e.id} className="card" style={{marginBottom:8,borderLeft:`3px solid ${color}`}}>
{isEd ? (
<EntryForm form={editing} setForm={setEditing}
onSave={handleUpdate} onCancel={()=>setEditing(null)} saveLabel="Speichern"/>
) : (
<div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
<div style={{flex:1}}>
<div style={{fontSize:14,fontWeight:600}}>{e.activity_type}</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:4}}>
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
{e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`}
</div>
<div style={{display:'flex',gap:10,flexWrap:'wrap'}}>
{e.duration_min && <span style={{fontSize:12,color:'var(--text2)'}}> {Math.round(e.duration_min)} Min</span>}
{e.kcal_active && <span style={{fontSize:12,color:'#EF9F27'}}>🔥 {Math.round(e.kcal_active)} kcal</span>}
{e.hr_avg && <span style={{fontSize:12,color:'var(--text2)'}}> Ø{Math.round(e.hr_avg)} bpm</span>}
{e.hr_max && <span style={{fontSize:12,color:'var(--text2)'}}>{Math.round(e.hr_max)} bpm</span>}
{e.distance_km && e.distance_km>0 && <span style={{fontSize:12,color:'var(--text2)'}}>📍 {Math.round(e.distance_km*10)/10} km</span>}
{e.rpe && <span style={{fontSize:12,color:'var(--text2)'}}>RPE {e.rpe}/10</span>}
{e.source==='apple_health' && <span style={{fontSize:10,color:'var(--text3)'}}>Apple Health</span>}
</div>
{e.notes && <p style={{fontSize:12,color:'var(--text2)',fontStyle:'italic',marginTop:4}}>"{e.notes}"</p>}
</div>
<div style={{display:'flex',gap:6,marginLeft:8}}>
<button className="btn btn-secondary" style={{padding:'5px 8px'}} onClick={()=>setEditing({...e})}><Pencil size={13}/></button>
<button className="btn btn-danger" style={{padding:'5px 8px'}} onClick={()=>handleDelete(e.id)}><Trash2 size={13}/></button>
</div>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,402 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2, Pencil, Check, X, Shield, Key } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import { api } from '../utils/api'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
function Avatar({ profile, size=36 }) {
const initials = profile.name.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2)
return (
<div style={{width:size,height:size,borderRadius:'50%',background:profile.avatar_color||'#1D9E75',
display:'flex',alignItems:'center',justifyContent:'center',
fontSize:size*0.35,fontWeight:700,color:'white',flexShrink:0}}>
{initials}
</div>
)
}
function Toggle({ value, onChange, label, disabled=false }) {
return (
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',
padding:'8px 0',borderBottom:'1px solid var(--border)'}}>
<span style={{fontSize:13,color:disabled?'var(--text3)':'var(--text1)'}}>{label}</span>
<div onClick={()=>!disabled&&onChange(!value)}
style={{width:40,height:22,borderRadius:11,background:value?'var(--accent)':'var(--border)',
position:'relative',cursor:disabled?'not-allowed':'pointer',transition:'background 0.2s',
opacity:disabled?0.5:1}}>
<div style={{position:'absolute',top:2,left:value?18:2,width:18,height:18,
borderRadius:'50%',background:'white',transition:'left 0.2s',
boxShadow:'0 1px 3px rgba(0,0,0,0.2)'}}/>
</div>
</div>
)
}
function NewProfileForm({ onSave, onCancel }) {
const [form, setForm] = useState({
name:'', pin:'', email:'', avatar_color:COLORS[0],
sex:'m', height:'', auth_type:'pin', session_days:30
})
const [error, setError] = useState(null)
const set = (k,v) => setForm(f=>({...f,[k]:v}))
const handleSave = async () => {
if (!form.name.trim()) return setError('Name eingeben')
if (form.pin.length < 4) return setError('PIN mind. 4 Zeichen')
try {
await onSave({...form, height:parseFloat(form.height)||178})
} catch(e) { setError(e.message) }
}
return (
<div style={{background:'var(--surface2)',borderRadius:10,padding:14,
border:'1.5px solid var(--accent)',marginBottom:12}}>
<div style={{fontWeight:600,fontSize:14,marginBottom:12,color:'var(--accent)'}}>Neues Profil</div>
<div className="form-row">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={form.name} onChange={e=>set('name',e.target.value)} autoFocus/>
<span className="form-unit"/>
</div>
<div style={{marginBottom:10}}>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:6}}>Farbe</div>
<div style={{display:'flex',gap:6}}>
{COLORS.map(c=>(
<div key={c} onClick={()=>set('avatar_color',c)}
style={{width:24,height:24,borderRadius:'50%',background:c,cursor:'pointer',
border:`3px solid ${form.avatar_color===c?'white':'transparent'}`,
boxShadow:form.avatar_color===c?`0 0 0 2px ${c}`:'none'}}/>
))}
</div>
</div>
<div className="form-row">
<label className="form-label">Geschlecht</label>
<select className="form-select" value={form.sex} onChange={e=>set('sex',e.target.value)}>
<option value="m">Männlich</option>
<option value="f">Weiblich</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Größe</label>
<input type="number" className="form-input" placeholder="178" value={form.height} onChange={e=>set('height',e.target.value)}/>
<span className="form-unit">cm</span>
</div>
<div className="form-row">
<label className="form-label">E-Mail</label>
<input type="email" className="form-input" placeholder="optional, für Recovery"
value={form.email} onChange={e=>set('email',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Login</label>
<select className="form-select" value={form.auth_type} onChange={e=>set('auth_type',e.target.value)}>
<option value="pin">PIN</option>
<option value="password">Passwort</option>
</select>
</div>
<div className="form-row">
<label className="form-label">PIN/Passwort</label>
<input type="password" className="form-input" placeholder="Mind. 4 Zeichen"
value={form.pin} onChange={e=>set('pin',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Session</label>
<select className="form-select" value={form.session_days} onChange={e=>set('session_days',parseInt(e.target.value))}>
<option value={7}>7 Tage</option>
<option value={30}>30 Tage</option>
<option value={0}>Immer</option>
</select>
</div>
{error && <div style={{color:'#D85A30',fontSize:12,marginBottom:8}}>{error}</div>}
<div style={{display:'flex',gap:8}}>
<button className="btn btn-primary" style={{flex:1}} onClick={handleSave}><Check size={13}/> Erstellen</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>
</div>
</div>
)
}
function EmailEditor({ profileId, currentEmail, onSaved }) {
const [email, setEmail] = useState(currentEmail||'')
const [msg, setMsg] = useState(null)
const save = async () => {
const token = localStorage.getItem('bodytrack_token')||''
await fetch(`/api/admin/profiles/${profileId}/email`, {
method:'PUT', headers:{'Content-Type':'application/json','X-Auth-Token':token},
body: JSON.stringify({email})
})
setMsg('✓ Gespeichert'); onSaved()
setTimeout(()=>setMsg(null),2000)
}
return (
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<input type="email" className="form-input" placeholder="email@beispiel.de"
value={email} onChange={e=>setEmail(e.target.value)} style={{flex:1}}/>
<button className="btn btn-secondary" onClick={save}>Setzen</button>
{msg && <span style={{fontSize:11,color:'var(--accent)'}}>{msg}</span>}
</div>
)
}
function ProfileCard({ profile, currentId, onRefresh }) {
const [expanded, setExpanded] = useState(false)
const [perms, setPerms] = useState({
ai_enabled: profile.ai_enabled ?? 1,
ai_limit_day: profile.ai_limit_day || '',
export_enabled: profile.export_enabled ?? 1,
role: profile.role || 'user',
})
const [saving, setSaving] = useState(false)
const [newPin, setNewPin] = useState('')
const [pinMsg, setPinMsg] = useState(null)
const isSelf = profile.id === currentId
const savePerms = async () => {
setSaving(true)
try {
await api.adminSetPermissions(profile.id, {
ai_enabled: perms.ai_enabled,
ai_limit_day: perms.ai_limit_day ? parseInt(perms.ai_limit_day) : null,
export_enabled: perms.export_enabled,
role: perms.role,
})
await onRefresh()
} finally { setSaving(false) }
}
const savePin = async () => {
if (newPin.length < 4) return setPinMsg('Mind. 4 Zeichen')
try {
await fetch(`/api/admin/profiles/${profile.id}/pin`, {
method:'PUT', headers:{'Content-Type':'application/json',
'X-Auth-Token': localStorage.getItem('bodytrack_token')||''},
body: JSON.stringify({pin: newPin})
})
setNewPin(''); setPinMsg('✓ PIN geändert')
setTimeout(()=>setPinMsg(null),2000)
} catch(e) { setPinMsg('Fehler: '+e.message) }
}
const deleteProfile = async () => {
if (!confirm(`Profil "${profile.name}" und ALLE Daten löschen?`)) return
await api.adminDeleteProfile(profile.id)
await onRefresh()
}
return (
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',gap:10}}>
<Avatar profile={profile}/>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:6}}>
{profile.name}
{profile.role==='admin' && <span style={{fontSize:10,color:'var(--accent)',background:'var(--accent-light)',padding:'1px 5px',borderRadius:4}}>👑 Admin</span>}
{isSelf && <span style={{fontSize:10,color:'var(--text3)'}}>Du</span>}
</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
KI: {profile.ai_enabled?`${profile.ai_limit_day?` (max ${profile.ai_limit_day}/Tag)`:''}` : '✗'} ·
Export: {profile.export_enabled?'✓':'✗'} ·
Calls heute: {profile.ai_calls_today||0}
</div>
</div>
<div style={{display:'flex',gap:6}}>
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
onClick={()=>setExpanded(e=>!e)}>
<Pencil size={12}/>
</button>
{!isSelf && (
<button className="btn btn-danger" style={{padding:'5px 8px'}}
onClick={deleteProfile}>
<Trash2 size={12}/>
</button>
)}
</div>
</div>
{expanded && (
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
{/* Permissions */}
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BERECHTIGUNGEN</div>
<div style={{marginBottom:8}}>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:4}}>Rolle</div>
<div style={{display:'flex',gap:6}}>
{['user','admin'].map(r=>(
<button key={r} onClick={()=>setPerms(p=>({...p,role:r}))}
style={{flex:1,padding:'6px',borderRadius:8,border:`1.5px solid ${perms.role===r?'var(--accent)':'var(--border2)'}`,
background:perms.role===r?'var(--accent-light)':'var(--surface)',
color:perms.role===r?'var(--accent-dark)':'var(--text2)',
fontFamily:'var(--font)',fontSize:13,cursor:'pointer'}}>
{r==='admin'?'👑 Admin':'👤 Nutzer'}
</button>
))}
</div>
</div>
<Toggle value={!!perms.ai_enabled} onChange={v=>setPerms(p=>({...p,ai_enabled:v?1:0}))} label="KI-Analysen erlaubt"/>
{!!perms.ai_enabled && (
<div className="form-row" style={{paddingTop:6}}>
<label className="form-label" style={{fontSize:12}}>Max. KI-Calls/Tag</label>
<input type="number" className="form-input" style={{width:70}} min={1} max={100}
placeholder="∞" value={perms.ai_limit_day}
onChange={e=>setPerms(p=>({...p,ai_limit_day:e.target.value}))}/>
<span className="form-unit" style={{fontSize:11}}>/Tag</span>
</div>
)}
<Toggle value={!!perms.export_enabled} onChange={v=>setPerms(p=>({...p,export_enabled:v?1:0}))} label="Daten-Export erlaubt"/>
<button className="btn btn-primary btn-full" style={{marginTop:10}} onClick={savePerms} disabled={saving}>
{saving?'Speichern…':'Berechtigungen speichern'}
</button>
{/* Email */}
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:6}}>E-MAIL (für Recovery & Zusammenfassungen)</div>
<EmailEditor profileId={profile.id} currentEmail={profile.email} onSaved={onRefresh}/>
</div>
{/* PIN change */}
<div style={{marginTop:14,paddingTop:12,borderTop:'1px solid var(--border)'}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8,display:'flex',alignItems:'center',gap:4}}>
<Key size={12}/> PIN / PASSWORT ÄNDERN
</div>
<div style={{display:'flex',gap:8}}>
<input type="password" className="form-input" placeholder="Neue PIN/Passwort"
value={newPin} onChange={e=>setNewPin(e.target.value)} style={{flex:1}}/>
<button className="btn btn-secondary" onClick={savePin}>Setzen</button>
</div>
{pinMsg && <div style={{fontSize:11,color:pinMsg.startsWith('✓')?'var(--accent)':'#D85A30',marginTop:4}}>{pinMsg}</div>}
</div>
</div>
)}
</div>
)
}
function EmailSettings() {
const [status, setStatus] = useState(null)
const [testTo, setTestTo] = useState('')
const [testing, setTesting] = useState(false)
const [testMsg, setTestMsg] = useState(null)
useEffect(()=>{
const token = localStorage.getItem('bodytrack_token')||''
fetch('/api/admin/email/status',{headers:{'X-Auth-Token':token}})
.then(r=>r.json()).then(setStatus)
},[])
const sendTest = async () => {
if (!testTo) return
setTesting(true); setTestMsg(null)
try {
const token = localStorage.getItem('bodytrack_token')||''
const r = await fetch('/api/admin/email/test',{
method:'POST',headers:{'Content-Type':'application/json','X-Auth-Token':token},
body:JSON.stringify({to:testTo})
})
if(!r.ok) throw new Error((await r.json()).detail)
setTestMsg('✓ Test-E-Mail gesendet!')
} catch(e){ setTestMsg('✗ Fehler: '+e.message) }
finally{ setTesting(false) }
}
return (
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:10,display:'flex',alignItems:'center',gap:6}}>
📧 E-Mail Konfiguration
</div>
{!status ? <div className="spinner" style={{width:16,height:16}}/> : (
<>
<div style={{padding:'8px 12px',borderRadius:8,marginBottom:12,
background:status.configured?'var(--accent-light)':'var(--warn-bg)',
fontSize:12,color:status.configured?'var(--accent-dark)':'var(--warn-text)'}}>
{status.configured
? <> Konfiguriert: <strong>{status.smtp_user}</strong> via {status.smtp_host}</>
: <> Nicht konfiguriert. SMTP-Einstellungen in der <code>.env</code> Datei setzen.</>}
</div>
{status.configured && (
<>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:10,lineHeight:1.5}}>
<strong>App-URL:</strong> {status.app_url}<br/>
<span style={{fontSize:10}}>Für korrekte Links in E-Mails (z.B. Recovery-Links). In .env als APP_URL setzen.</span>
</div>
<div style={{display:'flex',gap:8}}>
<input type="email" className="form-input" placeholder="test@beispiel.de"
value={testTo} onChange={e=>setTestTo(e.target.value)} style={{flex:1}}/>
<button className="btn btn-secondary" onClick={sendTest} disabled={testing}>
{testing?'…':'Test'}
</button>
</div>
{testMsg && <div style={{fontSize:12,marginTop:6,
color:testMsg.startsWith('✓')?'var(--accent)':'#D85A30'}}>{testMsg}</div>}
</>
)}
{!status.configured && (
<div style={{fontSize:11,color:'var(--text3)',lineHeight:1.6}}>
Füge folgende Zeilen zur <code>.env</code> Datei hinzu:<br/>
<code style={{background:'var(--surface2)',padding:'6px 8px',borderRadius:4,
display:'block',marginTop:6,fontSize:11}}>
SMTP_HOST=smtp.gmail.com<br/>
SMTP_PORT=587<br/>
SMTP_USER=deine@gmail.com<br/>
SMTP_PASS=dein_app_passwort<br/>
APP_URL=http://192.168.2.49:3002
</code>
</div>
)}
</>
)}
</div>
)
}
export default function AdminPanel() {
const { session } = useAuth()
const [profiles, setProfiles] = useState([])
const [creating, setCreating] = useState(false)
const [loading, setLoading] = useState(true)
const load = () => api.adminListProfiles().then(data=>{ setProfiles(data); setLoading(false) })
useEffect(()=>{ load() },[])
const handleCreate = async (form) => {
await api.adminCreateProfile(form)
setCreating(false)
await load()
}
if (loading) return <div className="empty-state"><div className="spinner"/></div>
return (
<div>
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:16}}>
<Shield size={18} color="var(--accent)"/>
<h2 style={{fontSize:17,fontWeight:700,margin:0}}>Benutzerverwaltung</h2>
</div>
<div style={{padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,
fontSize:12,color:'var(--accent-dark)',marginBottom:16,lineHeight:1.5}}>
👑 Du bist Admin. Hier kannst du Profile verwalten, Berechtigungen setzen und KI-Limits konfigurieren.
</div>
{creating && (
<NewProfileForm onSave={handleCreate} onCancel={()=>setCreating(false)}/>
)}
{profiles.map(p=>(
<ProfileCard key={p.id} profile={p} currentId={session?.profile_id} onRefresh={load}/>
))}
{!creating && (
<button className="btn btn-secondary btn-full" style={{marginTop:12}}
onClick={()=>setCreating(true)}>
<Plus size={14}/> Neues Profil anlegen
</button>
)}
{/* Email Settings */}
<EmailSettings/>
</div>
)
}

View File

@ -0,0 +1,425 @@
import { useState, useEffect } from 'react'
import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide-react'
import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import Markdown from '../utils/Markdown'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
const SLUG_LABELS = {
gesamt: '🔍 Gesamtanalyse',
koerper: '🫧 Körperkomposition',
ernaehrung: '🍽️ Ernährung',
aktivitaet: '🏋️ Aktivität',
gesundheit: '❤️ Gesundheitsindikatoren',
ziele: '🎯 Zielfortschritt',
pipeline: '🔬 Mehrstufige Gesamtanalyse',
pipeline_body: '🔬 Pipeline: Körper-Analyse (JSON)',
pipeline_nutrition: '🔬 Pipeline: Ernährungs-Analyse (JSON)',
pipeline_activity: '🔬 Pipeline: Aktivitäts-Analyse (JSON)',
pipeline_synthesis: '🔬 Pipeline: Synthese',
pipeline_goals: '🔬 Pipeline: Zielabgleich',
}
function InsightCard({ ins, onDelete, defaultOpen=false }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="card section-gap" style={{borderLeft:`3px solid var(--accent)`}}>
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:open?12:0,cursor:'pointer'}}
onClick={()=>setOpen(o=>!o)}>
<div style={{flex:1}}>
<div style={{fontSize:13,fontWeight:600}}>
{SLUG_LABELS[ins.scope] || ins.scope}
</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
{dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')}
</div>
</div>
<button style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)',padding:4}}
onClick={e=>{e.stopPropagation();onDelete(ins.id)}}>
<Trash2 size={13}/>
</button>
{open ? <ChevronUp size={16} color="var(--text3)"/> : <ChevronDown size={16} color="var(--text3)"/>}
</div>
{open && <Markdown text={ins.content}/>}
</div>
)
}
function PromptEditor({ prompt, onSave, onCancel }) {
const [template, setTemplate] = useState(prompt.template)
const [name, setName] = useState(prompt.name)
const [desc, setDesc] = useState(prompt.description||'')
const VARS = ['{{name}}','{{geschlecht}}','{{height}}','{{goal_weight}}','{{goal_bf_pct}}',
'{{weight_trend}}','{{weight_aktuell}}','{{kf_aktuell}}','{{caliper_summary}}',
'{{circ_summary}}','{{nutrition_summary}}','{{nutrition_detail}}',
'{{protein_ziel_low}}','{{protein_ziel_high}}','{{activity_summary}}',
'{{activity_kcal_summary}}','{{activity_detail}}']
return (
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:12}}>
<div className="card-title" style={{margin:0}}>Prompt bearbeiten</div>
<button style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}
onClick={onCancel}><X size={16}/></button>
</div>
<div className="form-row">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={name} onChange={e=>setName(e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<input type="text" className="form-input" value={desc} onChange={e=>setDesc(e.target.value)}/>
<span className="form-unit"/>
</div>
<div style={{marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:6}}>
Variablen (antippen zum Einfügen):
</div>
<div style={{display:'flex',flexWrap:'wrap',gap:4}}>
{VARS.map(v=>(
<button key={v} onClick={()=>setTemplate(t=>t+v)}
style={{fontSize:10,padding:'2px 7px',borderRadius:4,border:'1px solid var(--border2)',
background:'var(--surface2)',cursor:'pointer',fontFamily:'monospace',color:'var(--accent)'}}>
{v}
</button>
))}
</div>
</div>
<textarea value={template} onChange={e=>setTemplate(e.target.value)}
style={{width:'100%',minHeight:280,padding:10,fontFamily:'monospace',fontSize:12,
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
color:'var(--text1)',resize:'vertical',lineHeight:1.5,boxSizing:'border-box'}}/>
<div style={{display:'flex',gap:8,marginTop:8}}>
<button className="btn btn-primary" style={{flex:1}}
onClick={()=>onSave({name,description:desc,template})}>
<Check size={14}/> Speichern
</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}>Abbrechen</button>
</div>
</div>
)
}
export default function Analysis() {
const { canUseAI, isAdmin } = useAuth()
const [prompts, setPrompts] = useState([])
const [allInsights, setAllInsights] = useState([])
const [loading, setLoading] = useState(null)
const [error, setError] = useState(null)
const [editing, setEditing] = useState(null)
const [tab, setTab] = useState('run')
const [newResult, setNewResult] = useState(null)
const [pipelineLoading, setPipelineLoading] = useState(false)
const loadAll = async () => {
const [p, i] = await Promise.all([
fetch('/api/prompts').then(r=>r.json()),
api.listInsights()
])
setPrompts(Array.isArray(p)?p:[])
setAllInsights(Array.isArray(i)?i:[])
}
useEffect(()=>{ loadAll() },[])
const runPipeline = async () => {
setPipelineLoading(true); setError(null); setNewResult(null)
try {
const pid = localStorage.getItem('bodytrack_active_profile')||''
const r = await fetch('/api/insights/pipeline', {
method:'POST', headers: pid ? {'X-Profile-Id':pid} : {}
})
if (!r.ok) throw new Error(await r.text())
const result = await r.json()
setNewResult(result)
await loadAll()
setTab('run')
} catch(e) {
setError('Pipeline-Fehler: ' + e.message)
} finally { setPipelineLoading(false) }
}
const runPrompt = async (slug) => {
setLoading(slug); setError(null); setNewResult(null)
try {
const pid = localStorage.getItem('bodytrack_active_profile')||''
const r = await fetch(`/api/insights/run/${slug}`, {
method:'POST', headers: pid ? {'X-Profile-Id':pid} : {}
})
if (!r.ok) throw new Error(await r.text())
const result = await r.json()
setNewResult(result) // show immediately
await loadAll() // refresh lists
setTab('run') // stay on run tab to see result
} catch(e) {
setError('Fehler: ' + e.message)
} finally { setLoading(null) }
}
const savePrompt = async (promptId, data) => {
await fetch(`/api/prompts/${promptId}`, {
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data)
})
setEditing(null); await loadAll()
}
const deleteInsight = async (id) => {
if (!confirm('Analyse löschen?')) return
const pid = localStorage.getItem('bodytrack_active_profile')||''
await fetch(`/api/insights/${id}`, {
method:'DELETE', headers: pid ? {'X-Profile-Id':pid} : {}
})
if (newResult?.id === id) setNewResult(null)
await loadAll()
}
// Group insights by scope for history view
const grouped = {}
allInsights.forEach(ins => {
const key = ins.scope || 'sonstige'
grouped[key] = grouped[key] || []
grouped[key].push(ins)
})
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_'))
return (
<div>
<h1 className="page-title">KI-Analyse</h1>
<div className="tabs">
<button className={'tab'+(tab==='run'?' active':'')} onClick={()=>setTab('run')}>Analysen starten</button>
<button className={'tab'+(tab==='history'?' active':'')} onClick={()=>setTab('history')}>
Verlauf
{allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)',
color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>}
</button>
{isAdmin && <button className={'tab'+(tab==='prompts'?' active':'')} onClick={()=>setTab('prompts')}>Prompts</button>}
</div>
{error && (
<div style={{padding:'10px 14px',background:'#FCEBEB',borderRadius:8,fontSize:13,
color:'#D85A30',marginBottom:12,lineHeight:1.5}}>
{error.includes('nicht aktiviert') || error.includes('Limit')
? <>🔒 <strong>KI-Zugang eingeschränkt</strong><br/>
Dein Profil hat keinen Zugang zu KI-Analysen oder das Tageslimit wurde erreicht.
Bitte den Admin kontaktieren.</>
: error}
</div>
)}
{/* ── Analysen starten ── */}
{tab==='run' && (
<div>
{/* Fresh result shown immediately */}
{newResult && (
<div style={{marginBottom:16}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--accent)',marginBottom:8}}>
Neue Analyse erstellt:
</div>
<InsightCard
ins={{...newResult, created: new Date().toISOString()}}
onDelete={deleteInsight}
defaultOpen={true}
/>
</div>
)}
{/* Pipeline button */}
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}>
<div style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>🔬 Mehrstufige Gesamtanalyse</div>
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
dann Synthese + Zielabgleich. Detaillierteste Auswertung.
</div>
{allInsights.find(i=>i.scope==='pipeline') && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
Letzte Analyse: {dayjs(allInsights.find(i=>i.scope==='pipeline').created).format('DD.MM.YYYY, HH:mm')}
</div>
)}
</div>
<button className="btn btn-primary" style={{flexShrink:0,minWidth:100}}
onClick={runPipeline} disabled={!!loading||pipelineLoading}>
{pipelineLoading
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: <><Brain size={13}/> Starten</>}
</button>
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
</div>
{pipelineLoading && (
<div style={{marginTop:10,padding:'8px 12px',background:'var(--accent-light)',
borderRadius:8,fontSize:12,color:'var(--accent-dark)'}}>
Stufe 1: 3 parallele Analyse-Calls dann Synthese dann Zielabgleich
</div>
)}
</div>
{!canUseAI && (
<div style={{padding:'14px 16px',background:'#FCEBEB',borderRadius:10,
border:'1px solid #D85A3033',marginBottom:16}}>
<div style={{fontSize:14,fontWeight:600,color:'#D85A30',marginBottom:4}}>
🔒 KI-Analysen nicht freigeschaltet
</div>
<div style={{fontSize:13,color:'var(--text2)',lineHeight:1.5}}>
Dein Profil hat keinen Zugang zu KI-Analysen.
Bitte den Admin bitten, KI für dein Profil zu aktivieren
(Einstellungen Admin Profil bearbeiten).
</div>
</div>
)}
{canUseAI && <p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Oder wähle eine Einzelanalyse:
</p>}
{activePrompts.map(p => {
// Show latest existing insight for this prompt
const existing = allInsights.find(i=>i.scope===p.slug)
return (
<div key={p.id} className="card section-gap">
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:15}}>{SLUG_LABELS[p.slug]||p.name}</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>}
{existing && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
Letzte Auswertung: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')}
</div>
)}
</div>
<button className="btn btn-primary" style={{flexShrink:0,minWidth:90}}
onClick={()=>runPrompt(p.slug)} disabled={!!loading||!canUseAI}>
{loading===p.slug
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
</button>
</div>
{/* Show existing result collapsed */}
{existing && newResult?.id !== existing.id && (
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false}/>
</div>
)}
</div>
)
})}
{activePrompts.length===0 && (
<div className="empty-state"><p>Keine aktiven Prompts. Aktiviere im Tab "Prompts".</p></div>
)}
</div>
)}
{/* ── Verlauf gruppiert ── */}
{tab==='history' && (
<div>
{allInsights.length===0
? <div className="empty-state"><h3>Noch keine Analysen</h3></div>
: Object.entries(grouped).map(([scope, ins]) => (
<div key={scope} style={{marginBottom:20}}>
<div style={{fontSize:13,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
{SLUG_LABELS[scope]||scope} ({ins.length})
</div>
{ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight}/>)}
</div>
))
}
</div>
)}
{/* ── Prompts ── */}
{tab==='prompts' && (
<div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Passe Prompts an. Variablen wie{' '}
<code style={{fontSize:11,background:'var(--surface2)',padding:'1px 4px',borderRadius:3}}>{'{{name}}'}</code>{' '}
werden automatisch mit deinen Daten befüllt.
</p>
{editing ? (
<PromptEditor prompt={editing}
onSave={(data)=>savePrompt(editing.id,data)}
onCancel={()=>setEditing(null)}/>
) : (() => {
const singlePrompts = prompts.filter(p=>!p.slug.startsWith('pipeline_'))
const pipelinePrompts = prompts.filter(p=>p.slug.startsWith('pipeline_'))
const jsonSlugs = ['pipeline_body','pipeline_nutrition','pipeline_activity']
return (
<>
{/* Single prompts */}
<div style={{fontSize:12,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
Einzelanalysen
</div>
{singlePrompts.map(p=>(
<div key={p.id} className="card section-gap">
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
{SLUG_LABELS[p.slug]||p.name}
{!p.active && <span style={{fontSize:10,color:'var(--text3)',
background:'var(--surface2)',padding:'1px 6px',borderRadius:4}}>Inaktiv</span>}
</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
</div>
<button className="btn btn-secondary" style={{padding:'5px 8px',fontSize:12}}
onClick={()=>fetch(`/api/prompts/${p.id}`,{method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({active:p.active?0:1})}).then(loadAll)}>
{p.active?'Deaktiv.':'Aktiv.'}
</button>
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
onClick={()=>setEditing(p)}><Pencil size={13}/></button>
</div>
<div style={{marginTop:8,padding:'8px 10px',background:'var(--surface2)',borderRadius:6,
fontSize:11,fontFamily:'monospace',color:'var(--text3)',maxHeight:60,overflow:'hidden',lineHeight:1.4}}>
{p.template.slice(0,200)}
</div>
</div>
))}
{/* Pipeline prompts */}
<div style={{fontSize:12,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em',margin:'20px 0 8px'}}>
Mehrstufige Pipeline
</div>
<div style={{padding:'10px 12px',background:'var(--warn-bg)',borderRadius:8,
fontSize:12,color:'var(--warn-text)',marginBottom:12,lineHeight:1.6}}>
<strong>Hinweis:</strong> Pipeline-Stage-1-Prompts müssen valides JSON zurückgeben.
Halte das JSON-Format im Prompt erhalten. Stage 2 + 3 können frei angepasst werden.
</div>
{pipelinePrompts.map(p=>{
const isJson = jsonSlugs.includes(p.slug)
return (
<div key={p.id} className="card section-gap"
style={{borderLeft:`3px solid ${isJson?'var(--warn)':'var(--accent)'}`}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
{p.name}
{isJson && <span style={{fontSize:10,background:'var(--warn-bg)',
color:'var(--warn-text)',padding:'1px 6px',borderRadius:4}}>JSON-Output</span>}
</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
</div>
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
onClick={()=>setEditing(p)}><Pencil size={13}/></button>
</div>
<div style={{marginTop:8,padding:'8px 10px',background:'var(--surface2)',borderRadius:6,
fontSize:11,fontFamily:'monospace',color:'var(--text3)',maxHeight:80,overflow:'hidden',lineHeight:1.4}}>
{p.template.slice(0,300)}
</div>
</div>
)
})}
</>
)
})()}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,173 @@
import { useState, useEffect } from 'react'
import { Pencil, Trash2, Check, X, BookOpen } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc'
import { CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
import dayjs from 'dayjs'
function emptyForm() {
return {
date: dayjs().format('YYYY-MM-DD'), sf_method:'jackson3', notes:'',
sf_chest:'', sf_axilla:'', sf_triceps:'', sf_subscap:'',
sf_suprailiac:'', sf_abdomen:'', sf_thigh:'',
sf_calf_med:'', sf_lowerback:'', sf_biceps:''
}
}
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern' }) {
const sex = profile?.sex||'m'
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
const weight = form.weight || 80
const sfPoints = METHOD_POINTS[form.sf_method]?.[sex] || []
const sfVals = {}
sfPoints.forEach(k=>{ const v=form[`sf_${k}`]; if(v!==''&&v!=null) sfVals[k]=parseFloat(v) })
const bfPct = Object.keys(sfVals).length===sfPoints.length&&sfPoints.length>0
? Math.round(calcBodyFat(form.sf_method, sfVals, sex, age)*10)/10 : null
const bfCat = bfPct ? getBfCategory(bfPct, sex) : null
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Methode</label>
<select className="form-select" value={form.sf_method} onChange={e=>set('sf_method',e.target.value)}>
{Object.entries(CALIPER_METHODS).map(([k,m])=><option key={k} value={k}>{m.label}</option>)}
</select>
</div>
<div style={{padding:'7px 10px',background:'var(--warn-bg)',borderRadius:8,marginBottom:10,fontSize:12,color:'var(--warn-text)'}}>
Rechte Körperseite · Falte 1 cm abheben · Caliper 2 Sek. warten · 3× messen, Mittelwert
</div>
{sfPoints.map(k=>{
const p = CALIPER_POINTS[k]
return p ? (
<div key={k} className="form-row">
<label className="form-label" title={p.where}>{p.label}</label>
<input type="number" className="form-input" min={2} max={80} step={0.5}
placeholder="" value={form[`sf_${k}`]||''} onChange={e=>set(`sf_${k}`,e.target.value)}/>
<span className="form-unit">mm</span>
</div>
) : null
})}
{bfPct!==null && (
<div style={{margin:'10px 0',padding:'12px',background:'var(--accent-light)',borderRadius:8,textAlign:'center'}}>
<div style={{fontSize:28,fontWeight:700,color:bfCat?.color||'var(--accent)'}}>{bfPct}%</div>
<div style={{fontSize:12,color:'var(--accent-dark)'}}>{bfCat?.label} · {CALIPER_METHODS[form.sf_method]?.label}</div>
</div>
)}
<div className="form-row">
<label className="form-label">Notiz</label>
<input type="text" className="form-input" placeholder="optional" value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/>
</div>
<div style={{display:'flex',gap:6,marginTop:8}}>
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(bfPct, sex)}>{saveLabel}</button>
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
</div>
</div>
)
}
export default function CaliperScreen() {
const [entries, setEntries] = useState([])
const [profile, setProfile] = useState(null)
const [form, setForm] = useState(emptyForm())
const [editing, setEditing] = useState(null)
const [saved, setSaved] = useState(false)
const nav = useNavigate()
const load = () => Promise.all([api.listCaliper(), api.getProfile()])
.then(([e,p])=>{ setEntries(e); setProfile(p) })
useEffect(()=>{ load() },[])
const buildPayload = (f, bfPct, sex) => {
const weight = profile?.weight || null
const payload = { date: f.date, sf_method: f.sf_method, notes: f.notes }
Object.entries(f).forEach(([k,v])=>{ if(k.startsWith('sf_')&&v!==''&&v!=null) payload[k]=parseFloat(v) })
if(bfPct!=null) {
payload.body_fat_pct = bfPct
// get latest weight from profile or skip lean/fat
}
return payload
}
const handleSave = async (bfPct, sex) => {
const payload = buildPayload(form, bfPct, sex)
await api.upsertCaliper(payload)
setSaved(true); await load()
setTimeout(()=>setSaved(false),2000)
setForm(emptyForm())
}
const handleUpdate = async (bfPct, sex) => {
const payload = buildPayload(editing, bfPct, sex)
await api.updateCaliper(editing.id, payload)
setEditing(null); await load()
}
const handleDelete = async (id) => {
if(!confirm('Eintrag löschen?')) return
await api.deleteCaliper(id); await load()
}
return (
<div>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
<h1 className="page-title" style={{margin:0}}>Caliper</h1>
<button className="btn btn-secondary" style={{fontSize:12,padding:'6px 10px'}} onClick={()=>nav('/guide')}>
<BookOpen size={13}/> Anleitung
</button>
</div>
<div className="card section-gap">
<div className="card-title">Neue Messung</div>
<CaliperForm form={form} setForm={setForm} profile={profile}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
</div>
<div className="section-gap">
<div className="card-title" style={{marginBottom:8}}>Verlauf ({entries.length})</div>
{entries.length===0 && <p className="muted">Noch keine Caliper-Messungen.</p>}
{entries.map((e,i)=>{
const prev = entries[i+1]
const bfCat = e.body_fat_pct ? getBfCategory(e.body_fat_pct, profile?.sex||'m') : null
const isEd = editing?.id===e.id
return (
<div key={e.id} className="card" style={{marginBottom:8}}>
{isEd ? (
<CaliperForm form={editing} setForm={setEditing} profile={profile}
onSave={handleUpdate} onCancel={()=>setEditing(null)} saveLabel="Änderungen speichern"/>
) : (
<div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6}}>
<div style={{fontWeight:600,fontSize:14}}>{dayjs(e.date).format('DD. MMMM YYYY')}</div>
<div style={{display:'flex',gap:6}}>
<button className="btn btn-secondary" style={{padding:'5px 8px'}} onClick={()=>setEditing({...e})}><Pencil size={13}/></button>
<button className="btn btn-danger" style={{padding:'5px 8px'}} onClick={()=>handleDelete(e.id)}><Trash2 size={13}/></button>
</div>
</div>
{e.body_fat_pct && (
<div style={{display:'flex',gap:10,marginBottom:6}}>
<span style={{fontSize:22,fontWeight:700,color:bfCat?.color}}>{e.body_fat_pct}%</span>
{bfCat && <span style={{fontSize:12,alignSelf:'center',background:bfCat.color+'22',color:bfCat.color,padding:'2px 8px',borderRadius:6}}>{bfCat.label}</span>}
{prev?.body_fat_pct && <span style={{fontSize:12,alignSelf:'center',color:e.body_fat_pct<prev.body_fat_pct?'var(--accent)':'var(--warn)'}}>
{e.body_fat_pct<prev.body_fat_pct?'':''} {Math.abs(Math.round((e.body_fat_pct-prev.body_fat_pct)*10)/10)}%
</span>}
</div>
)}
<div style={{fontSize:11,color:'var(--text3)'}}>{e.sf_method} · {CALIPER_METHODS[e.sf_method]?.label}</div>
{e.notes && <p style={{fontSize:12,color:'var(--text2)',fontStyle:'italic',marginTop:4}}>"{e.notes}"</p>}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,98 @@
import { useNavigate } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
const ENTRIES = [
{
icon: '⚖️',
label: 'Gewicht',
sub: 'Tägliche Gewichtseingabe',
to: '/weight',
color: '#378ADD',
},
{
icon: '🪄',
label: 'Assistent',
sub: 'Schritt-für-Schritt Messung (Umfänge & Caliper)',
to: '/wizard',
color: '#7F77DD',
highlight: true,
},
{
icon: '📏',
label: 'Umfänge',
sub: 'Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Arm',
to: '/circum',
color: '#1D9E75',
},
{
icon: '📐',
label: 'Caliper',
sub: 'Körperfett per Hautfaltenmessung',
to: '/caliper',
color: '#D85A30',
},
{
icon: '🍽️',
label: 'Ernährung',
sub: 'FDDB CSV importieren',
to: '/nutrition',
color: '#EF9F27',
},
{
icon: '🏋️',
label: 'Aktivität',
sub: 'Training manuell oder Apple Health importieren',
to: '/activity',
color: '#D4537E',
},
{
icon: '📖',
label: 'Messanleitung',
sub: 'Wie und wo genau messen?',
to: '/guide',
color: '#888780',
},
]
export default function CaptureHub() {
const nav = useNavigate()
return (
<div>
<h1 className="page-title">Erfassen</h1>
<div style={{display:'flex',flexDirection:'column',gap:10}}>
{ENTRIES.map(e => (
<button key={e.to} onClick={()=>nav(e.to)}
style={{
display:'flex', alignItems:'center', gap:14,
padding:'14px 16px', borderRadius:14,
border: e.highlight ? `2px solid ${e.color}` : '1.5px solid var(--border)',
background: e.highlight ? e.color+'14' : 'var(--surface)',
cursor:'pointer', fontFamily:'var(--font)', textAlign:'left',
transition:'all 0.15s',
}}>
<div style={{
width:44, height:44, borderRadius:12,
background: e.color+'22',
display:'flex', alignItems:'center', justifyContent:'center',
fontSize:22, flexShrink:0,
}}>
{e.icon}
</div>
<div style={{flex:1}}>
<div style={{
fontSize:15, fontWeight:600,
color: e.highlight ? e.color : 'var(--text1)',
}}>
{e.label}
</div>
<div style={{fontSize:12, color:'var(--text3)', marginTop:2}}>
{e.sub}
</div>
</div>
<ChevronRight size={18} style={{color:'var(--text3)',flexShrink:0}}/>
</button>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,165 @@
import { useState, useEffect, useRef } from 'react'
import { Pencil, Trash2, Check, X, Camera, BookOpen } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
import { CIRCUMFERENCE_POINTS } from '../utils/guideData'
import dayjs from 'dayjs'
const FIELDS = ['c_neck','c_chest','c_waist','c_belly','c_hip','c_thigh','c_calf','c_arm']
const LABELS = {c_neck:'Hals',c_chest:'Brust',c_waist:'Taille',c_belly:'Bauch',c_hip:'Hüfte',c_thigh:'Oberschenkel',c_calf:'Wade',c_arm:'Oberarm'}
function empty() { return {date:dayjs().format('YYYY-MM-DD'), c_neck:'',c_chest:'',c_waist:'',c_belly:'',c_hip:'',c_thigh:'',c_calf:'',c_arm:'',notes:'',photo_id:''} }
export default function CircumScreen() {
const [entries, setEntries] = useState([])
const [form, setForm] = useState(empty())
const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [photoFile, setPhotoFile] = useState(null)
const [photoPreview, setPhotoPreview] = useState(null)
const fileRef = useRef()
const nav = useNavigate()
const load = () => api.listCirc().then(setEntries)
useEffect(()=>{ load() },[])
const set = (k,v) => setForm(f=>({...f,[k]:v}))
const handleSave = async () => {
setSaving(true)
try {
const payload = {}
payload.date = form.date
FIELDS.forEach(k=>{ if(form[k]!=='') payload[k]=parseFloat(form[k]) })
if(form.notes) payload.notes = form.notes
if(photoFile) {
const pr = await api.uploadPhoto(photoFile, form.date)
payload.photo_id = pr.id
}
await api.upsertCirc(payload)
setSaved(true); await load()
setTimeout(()=>setSaved(false),2000)
setForm(empty()); setPhotoFile(null); setPhotoPreview(null)
} finally { setSaving(false) }
}
const startEdit = (e) => setEditing({...e})
const cancelEdit = () => setEditing(null)
const handleUpdate = async () => {
const payload = {}
payload.date = editing.date
FIELDS.forEach(k=>{ if(editing[k]!=null && editing[k]!=='') payload[k]=parseFloat(editing[k]) })
if(editing.notes) payload.notes=editing.notes
await api.updateCirc(editing.id, payload)
setEditing(null); await load()
}
const handleDelete = async (id) => {
if(!confirm('Eintrag löschen?')) return
await api.deleteCirc(id); await load()
}
return (
<div>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
<h1 className="page-title" style={{margin:0}}>Umfänge</h1>
<button className="btn btn-secondary" style={{fontSize:12,padding:'6px 10px'}} onClick={()=>nav('/guide')}>
<BookOpen size={13}/> Anleitung
</button>
</div>
{/* Eingabe */}
<div className="card section-gap">
<div className="card-title">Neue Messung</div>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
<span className="form-unit"/>
</div>
{CIRCUMFERENCE_POINTS.map(p=>(
<div key={p.id} className="form-row">
<label className="form-label" title={p.where}>{p.label}</label>
<input type="number" className="form-input" min={10} max={200} step={0.1}
placeholder="" value={form[p.id]||''} onChange={e=>set(p.id,e.target.value)}/>
<span className="form-unit">cm</span>
</div>
))}
<div className="form-row">
<label className="form-label">Notiz</label>
<input type="text" className="form-input" placeholder="optional"
value={form.notes} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/>
</div>
{/* Photo */}
<input ref={fileRef} type="file" accept="image/*" capture="environment" style={{display:'none'}}
onChange={e=>{ const f=e.target.files[0]; if(f){ setPhotoFile(f); setPhotoPreview(URL.createObjectURL(f)) }}}/>
{photoPreview && <img src={photoPreview} style={{width:'100%',borderRadius:8,marginBottom:8}} alt="preview"/>}
<button className="btn btn-secondary btn-full" onClick={()=>fileRef.current.click()}>
<Camera size={14}/> {photoPreview?'Foto ändern':'Foto hinzufügen'}
</button>
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={handleSave} disabled={saving}>
{saved ? <><Check size={14}/> Gespeichert!</> : saving ? '…' : 'Speichern'}
</button>
</div>
{/* Liste */}
<div className="section-gap">
<div className="card-title" style={{marginBottom:8}}>Verlauf ({entries.length})</div>
{entries.length===0 && <p className="muted">Noch keine Einträge.</p>}
{entries.map((e,i)=>{
const prev = entries[i+1]
const isEd = editing?.id===e.id
return (
<div key={e.id} className="card" style={{marginBottom:8}}>
{isEd ? (
<div>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}}
value={editing.date} onChange={ev=>setEditing(d=>({...d,date:ev.target.value}))}/>
<span className="form-unit"/>
</div>
{CIRCUMFERENCE_POINTS.map(p=>(
<div key={p.id} className="form-row">
<label className="form-label">{p.label}</label>
<input type="number" className="form-input" step={0.1} placeholder=""
value={editing[p.id]||''} onChange={ev=>setEditing(d=>({...d,[p.id]:ev.target.value}))}/>
<span className="form-unit">cm</span>
</div>
))}
<div style={{display:'flex',gap:6,marginTop:8}}>
<button className="btn btn-primary" style={{flex:1}} onClick={handleUpdate}><Check size={13}/> Speichern</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={cancelEdit}><X size={13}/> Abbrechen</button>
</div>
</div>
) : (
<div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{fontWeight:600,fontSize:14}}>{dayjs(e.date).format('DD. MMMM YYYY')}</div>
<div style={{display:'flex',gap:6}}>
<button className="btn btn-secondary" style={{padding:'5px 8px'}} onClick={()=>startEdit(e)}><Pencil size={13}/></button>
<button className="btn btn-danger" style={{padding:'5px 8px'}} onClick={()=>handleDelete(e.id)}><Trash2 size={13}/></button>
</div>
</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:'3px 12px'}}>
{FIELDS.map(k=>e[k]!=null?(
<div key={k} style={{display:'flex',justifyContent:'space-between',fontSize:13,padding:'2px 0',borderBottom:'1px solid var(--border)'}}>
<span style={{color:'var(--text3)'}}>{LABELS[k]}</span>
<span>{e[k]} cm{prev?.[k]!=null&&<span style={{fontSize:11,color:e[k]<prev[k]?'var(--accent)':e[k]>prev[k]?'var(--warn)':'var(--text3)',marginLeft:4}}>
{e[k]-prev[k]>0?'+':''}{Math.round((e[k]-prev[k])*10)/10}
</span>}</span>
</div>
):null)}
</div>
{e.notes && <p style={{fontSize:12,color:'var(--text2)',fontStyle:'italic',marginTop:6}}>"{e.notes}"</p>}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,461 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, ChevronRight, Brain } from 'lucide-react'
import {
LineChart, Line, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid
} from 'recharts'
import { api } from '../utils/api'
import { useProfile } from '../context/ProfileContext'
import { getBfCategory } from '../utils/calc'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
// Helpers
function rollingAvg(arr, key, w=7) {
return arr.map((d,i)=>{
const s=arr.slice(Math.max(0,i-w+1),i+1).map(x=>x[key]).filter(v=>v!=null)
return s.length?{...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10}:d
})
}
// Quick Weight Entry
function QuickWeight({ onSaved }) {
const [input, setInput] = useState('')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const today = dayjs().format('YYYY-MM-DD')
useEffect(()=>{
api.weightStats().then(s=>{
if(s?.latest?.date===today) setInput(String(s.latest.weight))
})
},[])
const handleSave = async () => {
const w=parseFloat(input); if(!w||w<20||w>300) return
setSaving(true)
try{ await api.upsertWeight(today,w); setSaved(true); onSaved?.(); setTimeout(()=>setSaved(false),2000) }
finally{ setSaving(false) }
}
return (
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<input type="number" min={20} max={300} step={0.1} className="form-input"
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handleSave()}/>
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
<button className="btn btn-primary" style={{padding:'8px 14px'}}
onClick={handleSave} disabled={saving||!input}>
{saved?<Check size={15}/>:saving?<div className="spinner" style={{width:14,height:14}}/>:'Speichern'}
</button>
</div>
)
}
// Status Pill
const PILL_TOOLTIPS = {
'WHR': 'Waist-Hip-Ratio: Taille ÷ Hüfte. Maß für Bauchfettverteilung. Ziel: <0,90 (M) / <0,85 (F)',
'WHtR': 'Waist-to-Height-Ratio: Taille ÷ Körpergröße. Gesündestest Maß: Ziel unter 0,50.',
'KF': 'Körperfettanteil in Prozent (aus Caliper-Messung).',
'Protein Ø7T': 'Durchschnittliche tägliche Proteinaufnahme der letzten 7 Tage vs. Zielbereich (1,62,2g/kg KG).',
}
function Pill({ label, value, status, sub }) {
const [tip, setTip] = useState(false)
const color = status==='good'?'var(--accent)':status==='warn'?'var(--warn)':'#D85A30'
const bg = status==='good'?'var(--accent-light)':status==='warn'?'var(--warn-bg)':'#FCEBEB'
const tipText = PILL_TOOLTIPS[label]
return (
<div style={{position:'relative'}}>
<div onClick={()=>tipText&&setTip(s=>!s)}
style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',
borderRadius:20,background:bg,border:`1px solid ${color}44`,
cursor:tipText?'help':'default'}}>
<div style={{width:7,height:7,borderRadius:'50%',background:color,flexShrink:0}}/>
<span style={{fontSize:12,fontWeight:500,color:'var(--text2)'}}>{label}</span>
<span style={{fontSize:12,fontWeight:700,color}}>{value}</span>
{sub && <span style={{fontSize:10,color:'var(--text3)'}}>{sub}</span>}
{tipText && <span style={{fontSize:10,color:'var(--text3)',opacity:0.7}}></span>}
</div>
{tip && tipText && (
<div onClick={()=>setTip(false)} style={{
position:'absolute',bottom:'110%',left:0,zIndex:50,
background:'var(--surface)',border:'1px solid var(--border)',
borderRadius:8,padding:'8px 10px',fontSize:11,color:'var(--text2)',
minWidth:200,maxWidth:260,lineHeight:1.5,
boxShadow:'0 4px 16px rgba(0,0,0,0.15)'}}>
<strong>{label}</strong><br/>{tipText}
</div>
)}
</div>
)
}
// Stat Card
function StatCard({ icon, label, value, unit, delta, deltaGoodWhenNeg=false, sub, onClick, color }) {
const deltaColor = delta==null ? null
: (deltaGoodWhenNeg ? delta<0 : delta>0) ? 'var(--accent)' : 'var(--warn)'
return (
<div onClick={onClick} style={{
flex:1, minWidth:80, background:'var(--surface)', borderRadius:12,
padding:'12px 10px', cursor:onClick?'pointer':'default',
border:'1px solid var(--border)', transition:'border-color 0.15s',
}}
onMouseEnter={e=>onClick&&(e.currentTarget.style.borderColor='var(--accent)')}
onMouseLeave={e=>onClick&&(e.currentTarget.style.borderColor='var(--border)')}>
<div style={{fontSize:18,marginBottom:4}}>{icon}</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:2}}>{label}</div>
<div style={{fontSize:19,fontWeight:700,color:color||'var(--text1)',lineHeight:1.1}}>
{value}<span style={{fontSize:12,fontWeight:400,color:'var(--text3)',marginLeft:2}}>{unit}</span>
</div>
{delta!=null && <div style={{fontSize:11,fontWeight:600,color:deltaColor,marginTop:2}}>
{delta>0?'+':''}{delta} {unit}
</div>}
{sub && <div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>{sub}</div>}
</div>
)
}
// Combined Chart: Kcal + Weight
function ComboChart({ weights, nutrition }) {
// Build unified date axis from last 30 days
const days = []
for (let i=29; i>=0; i--) days.push(dayjs().subtract(i,'day').format('YYYY-MM-DD'))
const wMap = {}; (weights||[]).forEach(w=>{ wMap[w.date]=w.weight })
const nMap = {}; (nutrition||[]).forEach(n=>{ nMap[n.date]=Math.round(n.kcal||0) })
// Forward-fill weight: carry last known weight to fill gaps
let lastW = null
const combined = days.map(date=>{
if (wMap[date]) lastW = wMap[date]
return {
date: dayjs(date).format('DD.MM'),
kcal: nMap[date]||null,
weight: wMap[date]||null, // actual measurement dots
weightLine:lastW, // interpolated line
}
}).filter(d=>d.kcal||d.weightLine)
const withAvg = rollingAvg(combined,'kcal')
const hasKcal = combined.some(d=>d.kcal)
const hasW = combined.some(d=>d.weightLine)
if (!hasKcal && !hasW) return (
<div style={{padding:20,textAlign:'center',fontSize:12,color:'var(--text3)'}}>
Mehr Ernährungs- und Gewichtsdaten für den Chart nötig
</div>
)
return (
<ResponsiveContainer width="100%" height={160}>
<LineChart data={withAvg} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(withAvg.length/6)-1)}/>
{hasKcal && <YAxis yAxisId="kcal" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>}
{hasW && <YAxis yAxisId="weight" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>}
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[v==null?'':`${Math.round(v)} ${n==='weightLine'||n==='weight'?'kg':'kcal'}`,
n==='kcal_avg'?'Ø Kalorien (7T)':n==='kcal'?'Kalorien':n==='weightLine'?'Gewicht (interpoliert)':'Gewicht Messung']}/>
{hasKcal && <Line yAxisId="kcal" type="monotone" dataKey="kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} connectNulls={false}/>}
{hasKcal && <Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} connectNulls={true} name="kcal_avg"/>}
{hasW && <Line yAxisId="weight" type="monotone" dataKey="weightLine" stroke="#378ADD88" strokeWidth={1.5} dot={false} connectNulls={true} name="weightLine"/>}
{hasW && <Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={0}
dot={(props)=>{ const {cx,cy,value}=props; return value!=null?<circle key={cx} cx={cx} cy={cy} r={4} fill="#378ADD" stroke="white" strokeWidth={1.5}/>:<g key={cx}/>}} connectNulls={false} name="weight"/>}
</LineChart>
</ResponsiveContainer>
)
}
// Main Dashboard
export default function Dashboard() {
const nav = useNavigate()
const { activeProfile } = useProfile()
const [stats, setStats] = useState(null)
const [weights, setWeights] = useState([])
const [calipers, setCalipers] = useState([])
const [circs, setCircs] = useState([])
const [nutrition, setNutrition] = useState([])
const [activities,setActivities]= useState([])
const [insights, setInsights] = useState([])
const [loading, setLoading] = useState(true)
const [showInsight, setShowInsight] = useState(false)
const [pipelineLoading, setPipelineLoading] = useState(false)
const [pipelineError, setPipelineError] = useState(null)
const load = () => Promise.all([
api.getStats(),
api.listWeight(60),
api.listCaliper(3),
api.listCirc(2),
api.listNutrition(30),
api.listActivity(30),
api.latestInsights(),
]).then(([s,w,ca,ci,n,a,ins])=>{
setStats(s); setWeights(w); setCalipers(ca); setCircs(ci)
setNutrition(n); setActivities(a)
setInsights(Array.isArray(ins)?ins:[])
setLoading(false)
})
const runPipeline = async () => {
setPipelineLoading(true); setPipelineError(null)
try {
const pid = localStorage.getItem('mitai-jinkendo_active_profile')||''
const r = await fetch('/api/insights/pipeline', {
method:'POST', headers: pid ? {'X-Profile-Id':pid} : {}
})
if (!r.ok) throw new Error(await r.text())
await load()
} catch(e) {
setPipelineError('Fehler: '+e.message)
} finally { setPipelineLoading(false) }
}
useEffect(()=>{ load() },[])
if (loading) return <div className="empty-state"><div className="spinner"/></div>
const latestCal = calipers[0]
const latestCir = circs[0]
const latestW = weights[0]
const prevW = weights[1]
const sex = activeProfile?.sex||'m'
const height = activeProfile?.height||178
// Deltas
const wDelta = latestW&&prevW ? Math.round((latestW.weight-prevW.weight)*10)/10 : null
const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct,sex) : null
const bfPrev = calipers[1]?.body_fat_pct
const bfDelta = latestCal?.body_fat_pct&&bfPrev ? Math.round((latestCal.body_fat_pct-bfPrev)*10)/10 : null
// WHR / WHtR
const whr = latestCir?.c_waist&&latestCir?.c_hip ? Math.round(latestCir.c_waist/latestCir.c_hip*100)/100 : null
const whtr = latestCir?.c_waist&&height ? Math.round(latestCir.c_waist/height*100)/100 : null
// Nutrition averages (last 7 days)
const recentNutr = nutrition.filter(n=>n.date>=dayjs().subtract(7,'day').format('YYYY-MM-DD'))
const avgKcal = recentNutr.length ? Math.round(recentNutr.reduce((s,n)=>s+(n.kcal||0),0)/recentNutr.length) : null
const avgProtein = recentNutr.length ? Math.round(recentNutr.reduce((s,n)=>s+(n.protein_g||0),0)/recentNutr.length*10)/10 : null
const ptLow = Math.round((latestW?.weight||80)*1.6)
const proteinOk = avgProtein && avgProtein >= ptLow
// Activity (last 7 days)
const recentAct = activities.filter(a=>a.date>=dayjs().subtract(7,'day').format('YYYY-MM-DD'))
const actKcal = recentAct.length ? Math.round(recentAct.reduce((s,a)=>s+(a.kcal_active||0),0)) : null
// Status pills
const pills = []
if (whr) pills.push({label:'WHR', value:whr, status:whr<(sex==='m'?0.90:0.85)?'good':'warn', sub:`<${sex==='m'?'0,90':'0,85'}`})
if (whtr) pills.push({label:'WHtR', value:whtr, status:whtr<0.5?'good':'warn', sub:'<0,50'})
if (avgProtein) pills.push({label:'Protein Ø7T', value:avgProtein+'g', status:proteinOk?'good':'warn', sub:`Ziel ${ptLow}g`})
if (bfCat) pills.push({label:'KF', value:latestCal.body_fat_pct+'%', status:latestCal.body_fat_pct<(sex==='m'?18:25)?'good':'warn', sub:bfCat.label})
// Latest overall insight
const latestInsight = insights.find(i=>i.scope==='gesamt')||insights[0]
const hasAnyData = latestW||latestCal||nutrition.length>0
return (
<div>
{/* Header greeting */}
<div style={{marginBottom:16}}>
<h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}>
Hallo, {activeProfile?.name||'Nutzer'} 👋
</h1>
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
{dayjs().format('dddd, DD. MMMM YYYY')}
{latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`}
</div>
</div>
{!hasAnyData && (
<div className="empty-state">
<h3>Willkommen bei Mitai Jinkendo!</h3>
<p>Starte mit deiner ersten Messung.</p>
<button className="btn btn-primary" onClick={()=>nav('/capture')}>
Erfassen starten
</button>
</div>
)}
{hasAnyData && <>
{/* Quick weight entry */}
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:10}}>
<div style={{fontWeight:600,fontSize:14}}> Gewicht heute</div>
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}}
onClick={()=>nav('/weight')}>
Alle Einträge
</button>
</div>
<QuickWeight onSaved={load}/>
</div>
{/* Key metrics */}
<div style={{display:'flex',gap:8,marginBottom:16,flexWrap:'wrap'}}>
<StatCard icon="⚖️" label="Gewicht" value={latestW?.weight??''} unit="kg"
delta={wDelta} deltaGoodWhenNeg={true}
sub={latestW ? dayjs(latestW.date).format('DD.MM.') : ''}
onClick={()=>nav('/history')} color="#378ADD"/>
{latestCal?.body_fat_pct && <StatCard icon="🫧" label="Körperfett" value={latestCal.body_fat_pct} unit="%"
delta={bfDelta} deltaGoodWhenNeg={true}
sub={bfCat?.label}
onClick={()=>nav('/history',{state:{tab:'body'}})} color={bfCat?.color}/>}
{latestCal?.lean_mass && <StatCard icon="💪" label="Magermasse" value={latestCal.lean_mass} unit="kg"
sub={latestCal.date ? dayjs(latestCal.date).format('DD.MM.') : ''}
onClick={()=>nav('/history',{state:{tab:'body'}})}/>}
{avgKcal && <StatCard icon="🍽️" label="Ø Kalorien" value={avgKcal} unit="kcal"
sub="letzte 7 Tage" onClick={()=>nav('/history',{state:{tab:'nutrition'}})} color="#EF9F27"/>}
</div>
{/* Status pills */}
{pills.length > 0 && (
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:16}}>
{pills.map((p,i)=><Pill key={i} {...p}/>)}
</div>
)}
{/* Goals progress */}
{(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && (
<div className="card section-gap" style={{marginBottom:16}}>
<div style={{fontWeight:600,fontSize:13,marginBottom:10}}>🎯 Ziele</div>
{activeProfile?.goal_weight && latestW && (()=>{
const start = Math.max(...weights.map(w=>w.weight))
const curr = latestW.weight
const goal = activeProfile.goal_weight
const total = start - goal
const done = start - curr
const pct = total > 0 ? Math.min(100, Math.round(done/total*100)) : 100
const remain = Math.round((curr-goal)*10)/10
return (
<div style={{marginBottom:10}}>
<div style={{display:'flex',justifyContent:'space-between',fontSize:12,marginBottom:4}}>
<span>Gewicht: {curr} {goal} kg</span>
<span style={{color:'var(--accent)',fontWeight:600}}>{remain>0?`noch ${remain}kg`:'Ziel erreicht! 🎉'}</span>
</div>
<div style={{height:8,background:'var(--border)',borderRadius:4,overflow:'hidden'}}>
<div style={{height:'100%',width:`${pct}%`,background:'var(--accent)',borderRadius:4,transition:'width 0.5s'}}/>
</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>{pct}% des Weges</div>
</div>
)
})()}
{activeProfile?.goal_bf_pct && latestCal?.body_fat_pct && (()=>{
const curr = latestCal.body_fat_pct
const goal = activeProfile.goal_bf_pct
const remain= Math.round((curr-goal)*10)/10
const pct = curr<=goal ? 100 : Math.min(100,Math.round((1-(curr-goal)/Math.max(curr-goal,5))*100))
return (
<div>
<div style={{display:'flex',justifyContent:'space-between',fontSize:12,marginBottom:4}}>
<span>Körperfett: {curr}% {goal}%</span>
<span style={{color:'var(--accent)',fontWeight:600}}>{remain>0?`noch ${remain}%`:'Ziel erreicht! 🎉'}</span>
</div>
<div style={{height:8,background:'var(--border)',borderRadius:4,overflow:'hidden'}}>
<div style={{height:'100%',width:`${pct}%`,background:bfCat?.color||'var(--accent)',borderRadius:4}}/>
</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>Aktuell: {bfCat?.label}</div>
</div>
)
})()}
</div>
)}
{/* Combined chart */}
{(weights.length>2||nutrition.length>2) && (
<div className="card section-gap" style={{marginBottom:16}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{fontWeight:600,fontSize:13}}>📊 Kalorien + Gewicht (30 Tage)</div>
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}}
onClick={()=>nav('/history',{state:{tab:'body'}})}>
Details
</button>
</div>
<ComboChart weights={weights} nutrition={nutrition}/>
<div style={{display:'flex',gap:16,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#EF9F27',verticalAlign:'middle',marginRight:3}}/>Ø Kalorien</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD',verticalAlign:'middle',marginRight:3}}/>Gewicht</span>
</div>
</div>
)}
{/* Activity + Nutrition summary row */}
<div style={{display:'flex',gap:8,marginBottom:16}}>
{(avgKcal||avgProtein) && (
<div className="card" style={{flex:1,cursor:'pointer'}} onClick={()=>nav('/history',{state:{tab:'nutrition'}})}>
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🍽 ERNÄHRUNG (Ø 7T)</div>
{avgKcal && <div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{avgKcal} kcal</div>}
{avgProtein && <div style={{fontSize:13,fontWeight:600,
color:proteinOk?'var(--accent)':'var(--warn)'}}>
{avgProtein}g Protein {proteinOk?'✓':'⚠️'}
</div>}
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}> Verlauf Ernährung</div>
</div>
)}
{actKcal!=null && (
<div className="card" style={{flex:1,cursor:'pointer'}} onClick={()=>nav('/history',{state:{tab:'activity'}})}>
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🏋 AKTIVITÄT (7T)</div>
<div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{actKcal} kcal</div>
<div style={{fontSize:13,color:'var(--text2)'}}>{recentAct.length} Trainings</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}> Verlauf Aktivität</div>
</div>
)}
</div>
{/* Latest AI insight */}
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
<div style={{fontWeight:600,fontSize:13}}>🤖 KI-Auswertung</div>
<button className="btn btn-secondary" style={{fontSize:11,padding:'4px 10px'}}
onClick={()=>nav('/analysis')}>
<Brain size={11}/> Analysen
</button>
</div>
{/* Pipeline trigger */}
<button className="btn btn-primary btn-full" style={{marginBottom:10}}
onClick={runPipeline} disabled={pipelineLoading}>
{pipelineLoading
? <><div className="spinner" style={{width:13,height:13}}/> Analyse läuft (3 Stufen)</>
: <><Brain size={13}/> 🔬 Mehrstufige Analyse starten</>}
</button>
{pipelineError && <div style={{fontSize:12,color:'#D85A30',marginBottom:8}}>{pipelineError}</div>}
{latestInsight ? (
<>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:6}}>
Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')}
</div>
<div style={{maxHeight: showInsight?'none':120, overflow:'hidden', position:'relative'}}>
<Markdown text={latestInsight.content}/>
{!showInsight && (
<div style={{position:'absolute',bottom:0,left:0,right:0,height:40,
background:'linear-gradient(transparent,var(--surface))'}}/>
)}
</div>
<button style={{background:'none',border:'none',cursor:'pointer',
fontSize:12,color:'var(--accent)',marginTop:6,padding:0}}
onClick={()=>setShowInsight(s=>!s)}>
{showInsight?'▲ Weniger anzeigen':'▼ Vollständig anzeigen'}
</button>
</>
) : (
<div style={{fontSize:13,color:'var(--text3)',padding:'8px 0'}}>
Noch keine KI-Auswertung vorhanden.
<button className="btn btn-primary" style={{marginTop:8,display:'block',fontSize:12}}
onClick={()=>nav('/analysis')}>
Erste Analyse erstellen
</button>
</div>
)}
</div>
</>}
</div>
)
}

View File

@ -0,0 +1,114 @@
import { useState } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { CIRCUMFERENCE_POINTS, CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
function PointCard({ point, index }) {
const [open, setOpen] = useState(false)
return (
<div style={{border:`1px solid ${open ? point.color : 'var(--border)'}`,borderRadius:10,marginBottom:8,overflow:'hidden',transition:'border-color 0.15s'}}>
<div style={{display:'flex',alignItems:'center',gap:10,padding:'11px 14px',cursor:'pointer',background: open ? 'var(--surface2)' : 'var(--surface)'}}
onClick={() => setOpen(o => !o)}>
<div style={{width:28,height:28,borderRadius:'50%',background:point.color,display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}>
<span style={{fontSize:12,fontWeight:700,color:'white'}}>{index+1}</span>
</div>
<span style={{flex:1,fontSize:14,fontWeight:500}}>{point.label}</span>
{open ? <ChevronUp size={16} color="var(--text3)"/> : <ChevronDown size={16} color="var(--text3)"/>}
</div>
{open && (
<div style={{padding:'12px 14px',borderTop:'1px solid var(--border)',background:'var(--surface)'}}>
{[['📍 Wo', point.where], ['🧍 Haltung', point.posture], ['📏 Band', point.how], ['💡 Tipp', point.tip]].map(([label, val]) => (
<div key={label} style={{display:'grid',gridTemplateColumns:'90px 1fr',gap:'4px 8px',marginBottom:8}}>
<span style={{fontSize:11,fontWeight:600,color:'var(--text3)',textTransform:'uppercase',letterSpacing:'0.04em',paddingTop:2}}>{label}</span>
<span style={{fontSize:13,color:'var(--text2)',lineHeight:1.55}}>{val}</span>
</div>
))}
</div>
)}
</div>
)
}
export default function GuidePage() {
const [tab, setTab] = useState('circum')
const [caliperMethod, setCaliperMethod] = useState('jackson3')
const sex = 'm' // could read from profile
const methodPoints = CALIPER_METHODS[caliperMethod]?.points_m || []
return (
<div>
<h1 className="page-title">Messanleitung</h1>
<div className="tabs">
<button className={'tab' + (tab === 'circum' ? ' active' : '')} onClick={() => setTab('circum')}>Umfänge</button>
<button className={'tab' + (tab === 'caliper' ? ' active' : '')} onClick={() => setTab('caliper')}>Caliper</button>
<button className={'tab' + (tab === 'tips' ? ' active' : '')} onClick={() => setTab('tips')}>Allgemein</button>
</div>
{tab === 'circum' && (
<div>
<div style={{padding:'10px 14px',background:'var(--accent-light)',borderRadius:10,marginBottom:14,fontSize:13,color:'var(--accent-dark)',lineHeight:1.6}}>
Klicke auf einen Messpunkt für genaue Anleitung zu Körperhaltung und Messung.
</div>
{CIRCUMFERENCE_POINTS.map((p, i) => <PointCard key={p.id} point={p} index={i}/>)}
</div>
)}
{tab === 'caliper' && (
<div>
<div style={{marginBottom:14}}>
<div style={{fontSize:13,fontWeight:600,color:'var(--text3)',marginBottom:6}}>METHODE WÄHLEN</div>
<div style={{display:'flex',flexDirection:'column',gap:6}}>
{Object.entries(CALIPER_METHODS).map(([key, m]) => (
<button key={key} onClick={() => setCaliperMethod(key)}
style={{padding:'9px 14px',borderRadius:10,border:`1.5px solid ${caliperMethod===key?'var(--accent)':'var(--border2)'}`,
background: caliperMethod===key?'var(--accent-light)':'var(--surface)',
color: caliperMethod===key?'var(--accent-dark)':'var(--text2)',
fontFamily:'var(--font)',fontSize:13,fontWeight:500,textAlign:'left',cursor:'pointer',
display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span>{m.label}</span>
<span style={{fontSize:11,opacity:0.7}}>{m.points_m.length} Punkte</span>
</button>
))}
</div>
</div>
<div style={{padding:'10px 14px',background:'var(--warn-bg)',borderRadius:10,marginBottom:14,fontSize:13,color:'var(--warn-text)',lineHeight:1.6}}>
<strong>Caliper-Grundregel:</strong> Immer rechte Körperseite · Falte 1 cm neben dem Punkt abheben · Caliper 1 cm neben Fingern ansetzen · 2 Sek. warten · 3× messen, Mittelwert nehmen
</div>
{methodPoints.map((id, i) => {
const p = CALIPER_POINTS[id]
if (!p) return null
return <PointCard key={id} point={p} index={i}/>
})}
</div>
)}
{tab === 'tips' && (
<div>
{[
{ icon:'🌅', title:'Zeitpunkt', text:'Immer morgens nüchtern messen vor dem Frühstück und vor dem Sport. Zu festen Zeiten messen für vergleichbare Werte.' },
{ icon:'👗', title:'Kleidung', text:'Immer in vergleichbarer Unterwäsche oder ohne Kleidung messen. Kleidung kann die Werte verfälschen.' },
{ icon:'📏', title:'Maßband', text:'Maßband flach und waagerecht anlegen parallel zum Boden. Weder spannen noch locker lassen. 1 Finger Luft bei Hals-Messungen.' },
{ icon:'🔢', title:'Wiederholungen', text:'Jeden Wert 2× messen und den Durchschnitt notieren. Bei Abweichungen über 1 cm eine dritte Messung machen.' },
{ icon:'💧', title:'Wassereinlagerungen', text:'Nicht nach dem Sport, nach Alkohol, oder bei Krankheit messen. Beine schwellen gegen Abend an Wade morgens messen.' },
{ icon:'📅', title:'Intervall', text:'Wöchentliche oder zweiwöchentliche Messungen reichen. Tägliche Schwankungen von 12 kg sind normal und nicht aussagekräftig.' },
{ icon:'📸', title:'Fortschrittsfotos', text:'Fotos bei gleicher Beleuchtung und gleichem Hintergrund machen. Morgendlich nüchtern, gleiche Pose, gleiche Kamera-Distanz.' },
{ icon:'🔬', title:'Caliper vs. Maßband', text:'Caliper misst Unterhautfett direkt genauer als Umfänge allein. Umfänge erfassen die Gesamtgröße inklusive Muskeln. Beide Werte kombiniert geben das vollständigste Bild.' },
].map(item => (
<div key={item.title} className="card section-gap">
<div style={{display:'flex',gap:12,alignItems:'flex-start'}}>
<span style={{fontSize:22,flexShrink:0}}>{item.icon}</span>
<div>
<div style={{fontWeight:600,fontSize:14,marginBottom:4}}>{item.title}</div>
<div style={{fontSize:13,color:'var(--text2)',lineHeight:1.6}}>{item.text}</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,961 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, PieChart, Pie, Cell
} from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp } from 'lucide-react'
import { api } from '../utils/api'
import { getBfCategory } from '../utils/calc'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
function rollingAvg(arr, key, window=7) {
return arr.map((d,i) => {
const s = arr.slice(Math.max(0,i-window+1),i+1).map(x=>x[key]).filter(v=>v!=null)
return s.length ? {...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10} : d
})
}
const fmtDate = d => dayjs(d).format('DD.MM')
function NavToCaliper() {
const nav = useNavigate()
return <button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
onClick={()=>nav('/caliper')}>Caliper-Daten <ChevronRight size={10}/></button>
}
function NavToCircum() {
const nav = useNavigate()
return <button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
onClick={()=>nav('/circum')}>Umfang-Daten <ChevronRight size={10}/></button>
}
function EmptySection({ text, to, toLabel }) {
const nav = useNavigate()
return (
<div style={{padding:32,textAlign:'center',color:'var(--text3)',fontSize:13}}>
<div style={{marginBottom:12}}>{text}</div>
{to && <button className="btn btn-primary" onClick={()=>nav(to)}>{toLabel||'Daten erfassen'}</button>}
</div>
)
}
function SectionHeader({ title, to, toLabel, lastUpdated }) {
const nav = useNavigate()
return (
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14}}>
<h2 style={{fontSize:17,fontWeight:700,margin:0}}>{title}</h2>
<div style={{display:'flex',gap:8,alignItems:'center'}}>
{lastUpdated && <span style={{fontSize:11,color:'var(--text3)'}}>{dayjs(lastUpdated).format('DD.MM.YY')}</span>}
{to && (
<button className="btn btn-secondary" style={{fontSize:12,padding:'5px 10px'}} onClick={()=>nav(to)}>
{toLabel||'Daten'} <ChevronRight size={12}/>
</button>
)}
</div>
</div>
)
}
function RuleCard({ item }) {
const [open, setOpen] = useState(false)
const color = getStatusColor(item.status)
return (
<div style={{border:`1px solid ${color}33`,borderRadius:8,marginBottom:6,overflow:'hidden'}}>
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
background:getStatusBg(item.status)+'88',cursor:'pointer'}} onClick={()=>setOpen(o=>!o)}>
<span style={{fontSize:15}}>{item.icon}</span>
<div style={{flex:1}}>
<div style={{fontSize:11,fontWeight:600,color,textTransform:'uppercase',letterSpacing:'0.04em'}}>{item.category}</div>
<div style={{fontSize:13,fontWeight:500}}>{item.title}</div>
</div>
{item.value && <span style={{fontSize:14,fontWeight:700,color}}>{item.value}</span>}
{open ? <ChevronUp size={14} color="var(--text3)"/> : <ChevronDown size={14} color="var(--text3)"/>}
</div>
{open && <div style={{padding:'8px 12px',fontSize:12,color:'var(--text2)',lineHeight:1.6,
borderTop:`1px solid ${color}22`}}>{item.detail}</div>}
</div>
)
}
function InsightBox({ insights, slugs, onRequest, loading }) {
const [expanded, setExpanded] = useState(null)
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
const LABELS = {gesamt:'Gesamt',koerper:'Komposition',ernaehrung:'Ernährung',
aktivitaet:'Aktivität',gesundheit:'Gesundheit',ziele:'Ziele',
pipeline:'🔬 Mehrstufige Analyse',
pipeline_body:'Pipeline Körper',pipeline_nutrition:'Pipeline Ernährung',
pipeline_activity:'Pipeline Aktivität',pipeline_synthesis:'Pipeline Synthese',
pipeline_goals:'Pipeline Ziele'}
return (
<div style={{marginTop:14}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>🤖 KI-AUSWERTUNGEN</div>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{slugs.map(slug=>(
<button key={slug} className="btn btn-secondary" style={{fontSize:11,padding:'4px 8px'}}
onClick={()=>onRequest(slug)} disabled={loading===slug}>
{loading===slug
? <div className="spinner" style={{width:11,height:11}}/>
: <><Brain size={11}/> {LABELS[slug]||slug}</>}
</button>
))}
</div>
</div>
{relevant.length===0 && (
<div style={{padding:'10px 12px',background:'var(--surface2)',borderRadius:8,
fontSize:12,color:'var(--text3)'}}>
Noch keine Auswertung. Klicke oben um eine zu erstellen.
</div>
)}
{relevant.map(ins=>(
<div key={ins.id} style={{marginBottom:8,border:'1px solid var(--accent)33',borderRadius:10,overflow:'hidden'}}>
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
background:'var(--accent-light)66',cursor:'pointer'}}
onClick={()=>setExpanded(expanded===ins.id?null:ins.id)}>
<div style={{flex:1,fontSize:11,color:'var(--accent)',fontWeight:600}}>
{dayjs(ins.created).format('DD. MMM YYYY, HH:mm')} · {LABELS[ins.scope]||ins.scope}
</div>
{expanded===ins.id?<ChevronUp size={14}/>:<ChevronDown size={14}/>}
</div>
{expanded===ins.id && <div style={{padding:'12px 14px'}}><Markdown text={ins.content}/></div>}
</div>
))}
</div>
)
}
// Period selector
function PeriodSelector({ value, onChange }) {
const opts = [{v:30,l:'30 Tage'},{v:90,l:'90 Tage'},{v:180,l:'6 Monate'},{v:365,l:'1 Jahr'},{v:9999,l:'Alles'}]
return (
<div style={{display:'flex',gap:4,marginBottom:12}}>
{opts.map(o=>(
<button key={o.v} onClick={()=>onChange(o.v)}
style={{padding:'4px 10px',borderRadius:12,fontSize:11,fontWeight:500,border:'1.5px solid',
cursor:'pointer',fontFamily:'var(--font)',
background:value===o.v?'var(--accent)':'transparent',
borderColor:value===o.v?'var(--accent)':'var(--border2)',
color:value===o.v?'white':'var(--text2)'}}>
{o.l}
</button>
))}
</div>
)
}
// Body Section (Weight + Composition combined)
function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug }) {
const [period, setPeriod] = useState(90)
const sex = profile?.sex||'m'
const height = profile?.height||178
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
const filtW = [...(weights||[])].sort((a,b)=>a.date.localeCompare(b.date))
.filter(d=>period===9999||d.date>=cutoff)
const filtCal = (calipers||[]).filter(d=>period===9999||d.date>=cutoff)
const filtCir = (circs||[]).filter(d=>period===9999||d.date>=cutoff)
const hasWeight = filtW.length >= 2
const hasCal = filtCal.length >= 1
const hasCir = filtCir.length >= 1
if (!hasWeight && !hasCal && !hasCir) return (
<div>
<SectionHeader title="⚖️ Körper"/>
<PeriodSelector value={period} onChange={setPeriod}/>
<EmptySection text="Noch keine Körperdaten im gewählten Zeitraum." to="/weight" toLabel="Gewicht eintragen"/>
</div>
)
// Weight chart
const withAvg = rollingAvg(filtW,'weight')
const withAvg14= rollingAvg(filtW,'weight',14)
const wCd = withAvg.map((d,i)=>({
date:fmtDate(d.date),
weight:d.weight,
avg7: d.weight_avg,
avg14: withAvg14[i]?.weight_avg,
}))
const ws = filtW.map(w=>w.weight)
const minW = ws.length ? Math.min(...ws) : null
const maxW = ws.length ? Math.max(...ws) : null
const avgAll = ws.length ? Math.round(ws.reduce((a,b)=>a+b)/ws.length*10)/10 : null
const trendPeriods = [7,30,90].map(days=>{
const cut = dayjs().subtract(days,'day').format('YYYY-MM-DD')
const per = filtW.filter(d=>d.date>=cut)
if (per.length<2) return null
const diff = Math.round((per[per.length-1].weight-per[0].weight)*10)/10
return {label:`${days}T`,diff,count:per.length}
}).filter(Boolean)
// Caliper chart
const bfCd = [...filtCal].filter(c=>c.body_fat_pct).reverse().map(c=>({
date:fmtDate(c.date),bf:c.body_fat_pct,lean:c.lean_mass,fat:c.fat_mass
}))
const latestCal = filtCal[0]
const prevCal = filtCal[1]
const latestCir = filtCir[0]
const latestW2 = filtW[filtW.length-1]
const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct,sex) : null
// Circ chart
const cirCd = [...filtCir].filter(c=>c.c_waist||c.c_hip).reverse().map(c=>({
date:fmtDate(c.date),waist:c.c_waist,hip:c.c_hip,belly:c.c_belly
}))
// Indicators
const whr = latestCir?.c_waist&&latestCir?.c_hip ? Math.round(latestCir.c_waist/latestCir.c_hip*100)/100 : null
const whtr = latestCir?.c_waist&&height ? Math.round(latestCir.c_waist/height*100)/100 : null
// Rules
const combined = {
...(latestCal||{}),
c_waist:latestCir?.c_waist, c_hip:latestCir?.c_hip,
weight:latestW2?.weight
}
const rules = getInterpretation(combined, profile, prevCal||null)
return (
<div>
<SectionHeader title="⚖️ Körper" lastUpdated={weights[0]?.date||calipers[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
{/* Summary stats */}
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{latestW2 && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:16,fontWeight:700}}>{latestW2.weight} kg</div>
<div style={{fontSize:9,color:'var(--text3)'}}>Aktuell</div>
</div>}
{latestCal?.body_fat_pct && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:16,fontWeight:700,color:bfCat?.color}}>{latestCal.body_fat_pct}%</div>
<div style={{fontSize:9,color:'var(--text3)'}}>KF {bfCat?.label}</div>
</div>}
{latestCal?.lean_mass && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:16,fontWeight:700,color:'#1D9E75'}}>{latestCal.lean_mass} kg</div>
<div style={{fontSize:9,color:'var(--text3)'}}>Mager</div>
</div>}
{whr && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center',
borderTop:`3px solid ${whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:16,fontWeight:700,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>{whr}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>WHR</div>
</div>}
{whtr && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center',
borderTop:`3px solid ${whtr<0.5?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:16,fontWeight:700,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>{whtr}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>WHtR</div>
</div>}
</div>
{/* Weight chart 3 lines like WeightScreen */}
{hasWeight && (
<div className="card" style={{marginBottom:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>
Gewicht · {filtW.length} Einträge
</div>
<button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
onClick={()=>window.location.href='/weight'}>
Daten <ChevronRight size={10}/>
</button>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={wCd} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(wCd.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
{avgAll && <ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1}
label={{value:`Ø ${avgAll}`,fontSize:9,fill:'var(--text3)',position:'right'}}/>}
{profile?.goal_weight && <ReferenceLine y={profile.goal_weight} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5}
label={{value:`Ziel ${profile.goal_weight}kg`,fontSize:9,fill:'var(--accent)',position:'right'}}/>}
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v} kg`,n==='weight'?'Täglich':n==='avg7'?'Ø 7 Tage':'Ø 14 Tage']}/>
<Line type="monotone" dataKey="weight" stroke="#378ADD88" strokeWidth={1.5}
dot={{r:3,fill:'#378ADD',stroke:'#378ADD',strokeWidth:1}} activeDot={{r:5}} name="weight"/>
<Line type="monotone" dataKey="avg7" stroke="#378ADD" strokeWidth={2.5}
dot={false} name="avg7"/>
<Line type="monotone" dataKey="avg14" stroke="#1D9E75" strokeWidth={2}
dot={false} strokeDasharray="6 3" name="avg14"/>
</LineChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD88',verticalAlign:'middle',marginRight:3}}/> Täglich</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD',verticalAlign:'middle',marginRight:3}}/>Ø 7T</span>
<span style={{display:'inline-flex',alignItems:'center',gap:3}}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 3"/></svg>Ø 14T</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'var(--text3)',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed'}}/>Ø Gesamt</span>
</div>
{/* Trend tiles */}
{trendPeriods.length>0 && (
<div style={{display:'flex',gap:6,marginTop:10}}>
{trendPeriods.map(({label,diff})=>(
<div key={label} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'6px 8px',textAlign:'center',
borderTop:`3px solid ${diff<0?'var(--accent)':diff>0?'var(--warn)':'var(--border)'}`}}>
<div style={{fontSize:15,fontWeight:700,color:diff<0?'var(--accent)':diff>0?'var(--warn)':'var(--text3)'}}>
{diff>0?'+':''}{diff} kg
</div>
<div style={{fontSize:10,color:'var(--text3)'}}>{label}</div>
</div>
))}
{minW && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'6px 8px',textAlign:'center'}}>
<div style={{fontSize:12,color:'var(--accent)',fontWeight:600}}>{minW}</div>
<div style={{fontSize:12,color:'var(--warn)',fontWeight:600}}>{maxW}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>Min/Max</div>
</div>}
</div>
)}
</div>
)}
{/* KF + Magermasse chart */}
{bfCd.length>=2 && (
<div className="card" style={{marginBottom:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>KF% + Magermasse</div>
<NavToCaliper/>
</div>
<ResponsiveContainer width="100%" height={170}>
<LineChart data={bfCd} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis yAxisId="bf" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<YAxis yAxisId="lean" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v}${n==='bf'?'%':' kg'}`,n==='bf'?'KF%':'Mager']}/>
{profile?.goal_bf_pct && <ReferenceLine yAxisId="bf" y={profile.goal_bf_pct}
stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5}
label={{value:`Ziel ${profile.goal_bf_pct}%`,fontSize:9,fill:'#D85A30',position:'right'}}/>}
<Line yAxisId="bf" type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{r:4,fill:'#D85A30'}} name="bf"/>
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#1D9E75" strokeWidth={2} dot={{r:3,fill:'#1D9E75'}} name="lean"/>
</LineChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#D85A30',verticalAlign:'middle',marginRight:3}}/>KF%</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3}}/>Mager kg</span>
{profile?.goal_bf_pct && <span><span style={{display:'inline-block',width:14,height:2,background:'#D85A30',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #D85A30'}}/>Ziel KF</span>}
</div>
</div>
)}
{/* Circ trend */}
{cirCd.length>=2 && (
<div className="card" style={{marginBottom:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>Umfänge Verlauf</div>
<NavToCircum/>
</div>
<ResponsiveContainer width="100%" height={150}>
<LineChart data={cirCd} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v} cm`,n]}/>
<Line type="monotone" dataKey="waist" stroke="#EF9F27" strokeWidth={2} dot={{r:3}} name="Taille"/>
<Line type="monotone" dataKey="hip" stroke="#7F77DD" strokeWidth={2} dot={{r:3}} name="Hüfte"/>
{cirCd.some(d=>d.belly) && <Line type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{r:3}} name="Bauch"/>}
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* WHR / WHtR detail */}
{(whr||whtr) && (
<div style={{display:'flex',gap:8,marginBottom:12}}>
{whr && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'10px',textAlign:'center',
borderTop:`3px solid ${whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:20,fontWeight:700,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>{whr}</div>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)'}}>WHR</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Taille ÷ Hüfte</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Ziel &lt;{sex==='m'?'0,90':'0,85'}</div>
<div style={{fontSize:10,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>
{whr<(sex==='m'?0.90:0.85)?'✓ Günstig':'⚠️ Erhöht'}</div>
</div>}
{whtr && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'10px',textAlign:'center',
borderTop:`3px solid ${whtr<0.5?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:20,fontWeight:700,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>{whtr}</div>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)'}}>WHtR</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Taille ÷ Körpergröße</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Ziel &lt;0,50</div>
<div style={{fontSize:10,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>
{whtr<0.5?' Optimal':' Erhöht'}</div>
</div>}
</div>
)}
{rules.length>0 && (
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
{rules.map((item,i)=><RuleCard key={i} item={item}/>)}
</div>
)}
<InsightBox insights={insights} slugs={['pipeline','koerper','gesundheit','ziele']}
onRequest={onRequest} loading={loadingSlug}/>
</div>
)
}
// Nutrition Section
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug }) {
const [period, setPeriod] = useState(30)
if (!nutrition?.length) return (
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
)
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
const filtN = nutrition.filter(d=>period===9999||d.date>=cutoff)
const sorted = [...filtN].sort((a,b)=>a.date.localeCompare(b.date))
if (!filtN.length) return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import"/>
<PeriodSelector value={period} onChange={setPeriod}/>
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
</div>
)
const n = filtN.length
const avgKcal = Math.round(filtN.reduce((s,d)=>s+(d.kcal||0),0)/n)
const avgProtein = Math.round(filtN.reduce((s,d)=>s+(d.protein_g||0),0)/n*10)/10
const avgFat = Math.round(filtN.reduce((s,d)=>s+(d.fat_g||0),0)/n*10)/10
const avgCarbs = Math.round(filtN.reduce((s,d)=>s+(d.carbs_g||0),0)/n*10)/10
const latestW = weights?.[0]?.weight||80
const ptLow = Math.round(latestW*1.6)
const ptHigh = Math.round(latestW*2.2)
const proteinOk = avgProtein>=ptLow
// Stacked macro bar (daily)
const cdMacro = sorted.map(d=>({
date: fmtDate(d.date),
Protein: Math.round(d.protein_g||0),
KH: Math.round(d.carbs_g||0),
Fett: Math.round(d.fat_g||0),
kcal: Math.round(d.kcal||0),
}))
// Pie
const totalMacroKcal = avgProtein*4+avgCarbs*4+avgFat*9
const pieData = [
{name:'Protein',value:Math.round(avgProtein*4/totalMacroKcal*100),color:'#1D9E75'},
{name:'KH', value:Math.round(avgCarbs*4/totalMacroKcal*100), color:'#D4537E'},
{name:'Fett', value:Math.round(avgFat*9/totalMacroKcal*100), color:'#378ADD'},
]
// Weekly macro bars
const weeklyMap={}
filtN.forEach(d=>{
const wk=dayjs(d.date).format('YYYY-WW')
const weekNum = (() => { const dt=new Date(d.date); dt.setHours(0,0,0,0); dt.setDate(dt.getDate()+4-(dt.getDay()||7)); const y=new Date(dt.getFullYear(),0,1); return Math.ceil(((dt-y)/86400000+1)/7) })()
if(!weeklyMap[wk]) weeklyMap[wk]={label:'KW'+weekNum,n:0,protein:0,carbs:0,fat:0,kcal:0}
weeklyMap[wk].protein+=d.protein_g||0; weeklyMap[wk].carbs+=d.carbs_g||0
weeklyMap[wk].fat+=d.fat_g||0; weeklyMap[wk].kcal+=d.kcal||0; weeklyMap[wk].n++
})
const weeklyData=Object.values(weeklyMap).slice(-12).map(w=>({
label:w.label,
Protein:Math.round(w.protein/w.n),
KH:Math.round(w.carbs/w.n),
Fett:Math.round(w.fat/w.n),
kcal:Math.round(w.kcal/w.n),
}))
// Rules
const macroRules=[]
if(!proteinOk) macroRules.push({status:'bad',icon:'🥩',category:'Protein',
title:`Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`,
detail:`1,62,2g/kg KG. Fehlend: ~${ptLow-Math.round(avgProtein)}g täglich. Konsequenz: Muskelverlust bei Defizit.`,
value:avgProtein+'g'})
else macroRules.push({status:'good',icon:'🥩',category:'Protein',
title:`Gut: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`,
detail:`Ausreichend für Muskelerhalt und -aufbau.`,value:avgProtein+'g'})
const protPct=Math.round(avgProtein*4/totalMacroKcal*100)
if(protPct<20) macroRules.push({status:'warn',icon:'📊',category:'Makro-Anteil',
title:`Protein-Anteil niedrig: ${protPct}% der Kalorien`,
detail:`Empfehlung: 2535%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs*4/totalMacroKcal*100)}% KH / ${Math.round(avgFat*9/totalMacroKcal*100)}% F`,
value:protPct+'%'})
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={nutrition[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{[['Ø Kalorien',avgKcal+' kcal','#EF9F27'],['Ø Protein',avgProtein+'g',proteinOk?'#1D9E75':'#D85A30'],
['Ø Fett',avgFat+'g','#378ADD'],['Ø KH',avgCarbs+'g','#D4537E'],
['Einträge',n+' T','var(--text3)']].map(([l,v,c])=>(
<div key={l} style={{flex:1,minWidth:60,background:'var(--surface2)',borderRadius:8,
padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:13,fontWeight:700,color:c}}>{v}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
</div>
))}
</div>
{/* Stacked macro bars (daily) */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} {sorted[sorted.length-1]?.date?.slice(0,7)}
</div>
<ResponsiveContainer width="100%" height={170}>
<BarChart data={cdMacro} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(cdMacro.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
label={{value:`Ziel ${ptLow}g P`,fontSize:9,fill:'#1D9E75',position:'insideTopRight'}}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[2,2,0,0]}/>
</BarChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:10,height:10,background:'#1D9E7599',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Protein</span>
<span><span style={{display:'inline-block',width:10,height:10,background:'#D4537E99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>KH</span>
<span><span style={{display:'inline-block',width:10,height:10,background:'#378ADD99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Fett</span>
<span><span style={{display:'inline-block',width:14,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #1D9E75'}}/>Protein-Ziel</span>
</div>
</div>
{/* Pie + macro breakdown */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:10}}>
Ø Makroverteilung · {n} Tage ({sorted[0]?.date?.slice(0,10)} {sorted[sorted.length-1]?.date?.slice(0,10)})
</div>
<div style={{display:'flex',alignItems:'center',gap:16}}>
<PieChart width={110} height={110}>
<Pie data={pieData} cx={50} cy={50} innerRadius={32} outerRadius={50}
dataKey="value" startAngle={90} endAngle={-270}>
{pieData.map((e,i)=><Cell key={i} fill={e.color}/>)}
</Pie>
<Tooltip formatter={(v,n)=>[`${v}%`,n]}/>
</PieChart>
<div style={{flex:1}}>
{pieData.map(p=>(
<div key={p.name} style={{display:'flex',alignItems:'center',gap:8,marginBottom:7}}>
<div style={{width:10,height:10,borderRadius:2,background:p.color,flexShrink:0}}/>
<div style={{flex:1,fontSize:13}}>{p.name}</div>
<div style={{fontSize:13,fontWeight:600,color:p.color}}>{p.value}%</div>
<div style={{fontSize:11,color:'var(--text3)'}}>{Math.round(p.name==='Protein'?avgProtein:p.name==='KH'?avgCarbs:avgFat)}g</div>
{p.name==='Protein' && <div style={{fontSize:10,color:proteinOk?'var(--accent)':'var(--warn)',marginLeft:2}}>
{proteinOk?'✓':'⚠️'} Ziel {ptLow}g
</div>}
</div>
))}
<div style={{marginTop:6,fontSize:11,color:'var(--text3)',borderTop:'1px solid var(--border)',paddingTop:6}}>
Gesamt: {avgKcal} kcal/Tag
</div>
</div>
</div>
</div>
{/* Weekly stacked bars */}
{weeklyData.length>=2 && (
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Makros pro Woche (Ø g/Tag)</div>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={weeklyData} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="label" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[3,3,0,0]}/>
</BarChart>
</ResponsiveContainer>
</div>
)}
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
{macroRules.map((item,i)=><RuleCard key={i} item={item}/>)}
</div>
<InsightBox insights={insights} slugs={['ernaehrung']} onRequest={onRequest} loading={loadingSlug}/>
</div>
)
}
// Activity Section
function ActivitySection({ activities, insights, onRequest, loadingSlug }) {
const [period, setPeriod] = useState(30)
if (!activities?.length) return (
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/>
)
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
const filtA = activities.filter(d=>period===9999||d.date>=cutoff)
const byDate={}
filtA.forEach(a=>{ byDate[a.date]=(byDate[a.date]||0)+(a.kcal_active||0) })
const cd=Object.entries(byDate).sort((a,b)=>a[0].localeCompare(b[0])).map(([date,kcal])=>({date:fmtDate(date),kcal:Math.round(kcal)}))
const totalKcal=Math.round(filtA.reduce((s,a)=>s+(a.kcal_active||0),0))
const totalMin =Math.round(filtA.reduce((s,a)=>s+(a.duration_min||0),0))
const hrData =filtA.filter(a=>a.hr_avg)
const avgHr =hrData.length?Math.round(hrData.reduce((s,a)=>s+a.hr_avg,0)/hrData.length):null
const types={}; filtA.forEach(a=>{ types[a.activity_type]=(types[a.activity_type]||0)+1 })
const topTypes=Object.entries(types).sort((a,b)=>b[1]-a[1])
const daysWithAct=new Set(filtA.map(a=>a.date)).size
const totalDays=Math.min(period,dayjs().diff(dayjs(filtA[filtA.length-1]?.date),'day')+1)
const consistency=totalDays>0?Math.round(daysWithAct/totalDays*100):0
const actRules=[{
status:consistency>=70?'good':consistency>=40?'warn':'bad',
icon:'📅', category:'Konsistenz',
title:`${consistency}% aktive Tage (${daysWithAct}/${Math.min(period,30)} Tage)`,
detail:consistency>=70?'Ausgezeichnete Regelmäßigkeit.':consistency>=40?'Ziel: 45 Einheiten/Woche.':'Mehr Regelmäßigkeit empfohlen.',
value:consistency+'%'
}]
return (
<div>
<SectionHeader title="🏋️ Aktivität" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<div style={{display:'flex',gap:6,marginBottom:12}}>
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'],
['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'],
avgHr?['Ø HF',avgHr+' bpm','#D85A30']:null].filter(Boolean).map(([l,v,c])=>(
<div key={l} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:14,fontWeight:700,color:c}}>{v}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
</div>
))}
</div>
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Aktive Kalorien / Tag</div>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={cd} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(cd.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={v=>[`${v} kcal`]}/>
<Bar dataKey="kcal" fill="#EF9F2788" radius={[3,3,0,0]}/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingsarten</div>
{topTypes.map(([type,count])=>(
<div key={type} style={{display:'flex',alignItems:'center',gap:8,padding:'4px 0',borderBottom:'1px solid var(--border)'}}>
<div style={{flex:1,fontSize:13}}>{type}</div>
<div style={{fontSize:12,color:'var(--text3)'}}>{count}×</div>
<div style={{width:Math.max(4,Math.round(count/filtA.length*80)),height:6,background:'#EF9F2788',borderRadius:3}}/>
</div>
))}
</div>
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
</div>
<InsightBox insights={insights} slugs={['aktivitaet']} onRequest={onRequest} loading={loadingSlug}/>
</div>
)
}
// Correlation Section
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug }) {
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight)
if (filtered.length < 5) return (
<EmptySection text="Für Korrelationen werden Gewichts- und Ernährungsdaten benötigt (mind. 5 gemeinsame Tage)."/>
)
const sex = profile?.sex||'m'
const height = profile?.height||178
const latestW = filtered[filtered.length-1]?.weight||80
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 35
const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161
const tdee = Math.round(bmr*1.4) // light activity baseline
// Chart 1: Kcal vs Weight
const kcalVsW = rollingAvg(filtered.map(d=>({...d,date:fmtDate(d.date)})),'kcal')
// Chart 2: Protein vs Lean Mass (only days with both)
const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass)
.map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass}))
// Chart 3: Activity kcal vs Weight change
const actVsW = filtered.filter(d=>d.weight)
.map((d,i,arr)=>{
const prev = arr[i-1]
return {
date: fmtDate(d.date),
weight: d.weight,
weightDelta: prev ? Math.round((d.weight-prev.weight)*10)/10 : null,
kcal: d.kcal||0,
}
}).filter(d=>d.weightDelta!==null)
// Chart 4: Calorie balance (intake - estimated TDEE)
const balance = filtered.map(d=>({
date: fmtDate(d.date),
balance: Math.round((d.kcal||0) - tdee),
}))
const balWithAvg = rollingAvg(balance,'balance')
const avgBalance = Math.round(balance.reduce((s,d)=>s+d.balance,0)/balance.length)
// Correlation insights
const corrInsights = []
// 1. Kcal Weight correlation
if (filtered.length >= 14) {
const highKcal = filtered.filter(d=>d.kcal>tdee+200)
const lowKcal = filtered.filter(d=>d.kcal<tdee-200)
if (highKcal.length>=3 && lowKcal.length>=3) {
const avgWHigh = Math.round(highKcal.reduce((s,d)=>s+d.weight,0)/highKcal.length*10)/10
const avgWLow = Math.round(lowKcal.reduce((s,d)=>s+d.weight,0)/lowKcal.length*10)/10
corrInsights.push({
icon:'📊', status: avgWLow < avgWHigh ? 'good' : 'warn',
title: avgWLow < avgWHigh
? `Kalorienreduktion wirkt: Ø ${avgWLow}kg bei Defizit vs. ${avgWHigh}kg bei Überschuss`
: `Kein klarer Kalorieneffekt auf Gewicht erkennbar`,
detail: `Tage mit Überschuss (>${tdee+200} kcal): Ø ${avgWHigh}kg · Tage mit Defizit (<${tdee-200} kcal): Ø ${avgWLow}kg`,
})
}
}
// 2. Protein Lean mass
if (protVsLean.length >= 3) {
const ptLow = Math.round(latestW*1.6)
const highProt = protVsLean.filter(d=>d.protein>=ptLow)
const lowProt = protVsLean.filter(d=>d.protein<ptLow)
if (highProt.length>=2 && lowProt.length>=2) {
const avgLH = Math.round(highProt.reduce((s,d)=>s+d.lean,0)/highProt.length*10)/10
const avgLL = Math.round(lowProt.reduce((s,d)=>s+d.lean,0)/lowProt.length*10)/10
corrInsights.push({
icon:'🥩', status: avgLH >= avgLL ? 'good' : 'warn',
title: `Hohe Proteinzufuhr (≥${ptLow}g): Ø ${avgLH}kg Mager · Niedrig: Ø ${avgLL}kg`,
detail: `${highProt.length} Messpunkte mit hoher vs. ${lowProt.length} mit niedriger Proteinzufuhr verglichen.`,
})
}
}
// 3. Avg balance
corrInsights.push({
icon: avgBalance < -100 ? '✅' : avgBalance > 200 ? '⬆️' : '➡️',
status: avgBalance < -100 ? 'good' : avgBalance > 300 ? 'warn' : 'good',
title: `Ø Kalorienbilanz: ${avgBalance>0?'+':''}${avgBalance} kcal/Tag`,
detail: `Geschätzter TDEE: ${tdee} kcal (Mifflin-St Jeor ×1,4). ${
avgBalance<-500?'Starkes Defizit Muskelerhalt durch ausreichend Protein sicherstellen.':
avgBalance<-100?'Moderates Defizit ideal für Fettabbau bei Muskelerhalt.':
avgBalance>300?'Kalorienüberschuss günstig für Muskelaufbau, Fettzunahme möglich.':
'Nahezu ausgeglichen Gewicht sollte stabil bleiben.'}`,
})
return (
<div>
<SectionHeader title="🔗 Korrelationen"/>
{/* Chart 1: Kcal vs Weight */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
📉 Kalorien (Ø 7T) vs. Gewicht
</div>
<ResponsiveContainer width="100%" height={190}>
<LineChart data={kcalVsW} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(kcalVsW.length/6)-1)}/>
<YAxis yAxisId="kcal" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<YAxis yAxisId="weight" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`,n==='kcal_avg'?'Ø Kalorien':'Gewicht']}/>
<ReferenceLine yAxisId="kcal" y={tdee} stroke="var(--text3)" strokeDasharray="3 3" strokeWidth={1}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} name="kcal_avg"/>
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2.5} dot={{r:2,fill:'#378ADD'}} name="weight"/>
</LineChart>
</ResponsiveContainer>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
Gestrichelt: geschätzter TDEE {tdee} kcal · <span style={{color:'#EF9F27'}}> Kalorien</span> · <span style={{color:'#378ADD'}}> Gewicht</span>
</div>
</div>
{/* Chart 2: Calorie balance */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
Kalorienbilanz (Aufnahme TDEE {tdee} kcal)
</div>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={balWithAvg} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(balWithAvg.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v>0?'+':''}${v} kcal`,n==='balance_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/>
<Line type="monotone" dataKey="balance" stroke="#EF9F2744" strokeWidth={1} dot={false}/>
<Line type="monotone" dataKey="balance_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_avg"/>
</LineChart>
</ResponsiveContainer>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
Über 0 = Überschuss · Unter 0 = Defizit · Ø {avgBalance>0?'+':''}{avgBalance} kcal/Tag
</div>
</div>
{/* Chart 3: Protein vs Lean Mass */}
{protVsLean.length >= 3 && (
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
🥩 Protein vs. Magermasse
</div>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={protVsLean} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis yAxisId="prot" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<YAxis yAxisId="lean" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<ReferenceLine yAxisId="prot" y={Math.round(latestW*1.6)} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
label={{value:`Ziel ${Math.round(latestW*1.6)}g`,fontSize:9,fill:'#1D9E75',position:'right'}}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v}${n==='protein'?'g':' kg'}`,n==='protein'?'Protein':'Mager']}/>
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein"/>
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{r:4,fill:'#7F77DD'}} name="lean"/>
</LineChart>
</ResponsiveContainer>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
<span style={{color:'#1D9E75'}}> Protein g/Tag</span> · <span style={{color:'#7F77DD'}}> Magermasse kg</span>
</div>
</div>
)}
{/* Correlation insights */}
{corrInsights.length > 0 && (
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>KORRELATIONSAUSSAGEN</div>
{corrInsights.map((item,i) => (
<div key={i} style={{padding:'10px 12px',borderRadius:8,marginBottom:6,
background:item.status==='good'?'var(--accent-light)':'var(--warn-bg)',
border:`1px solid ${item.status==='good'?'var(--accent)':'var(--warn)'}33`}}>
<div style={{display:'flex',gap:8,alignItems:'flex-start'}}>
<span style={{fontSize:16}}>{item.icon}</span>
<div>
<div style={{fontSize:13,fontWeight:600}}>{item.title}</div>
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>{item.detail}</div>
</div>
</div>
</div>
))}
<div style={{fontSize:11,color:'var(--text3)',padding:'6px 10px',background:'var(--surface2)',
borderRadius:6,marginTop:6}}>
TDEE-Schätzung basiert auf Mifflin-St Jeor ×1,4 (leicht aktiv). Für genauere Werte Aktivitätsdaten erfassen.
</div>
</div>
)}
<InsightBox insights={insights} slugs={['gesamt','ziele']} onRequest={onRequest} loading={loadingSlug}/>
</div>
)
}
// Photo Grid
function PhotoGrid() {
const [photos,setPhotos]=useState([])
const [big,setBig]=useState(null)
useEffect(()=>{ api.listPhotos().then(setPhotos) },[])
if(!photos.length) return <EmptySection text="Noch keine Fotos." to="/circum" toLabel="Umfänge erfassen"/>
return (
<>
{big&&<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.9)',zIndex:100,
display:'flex',alignItems:'center',justifyContent:'center'}} onClick={()=>setBig(null)}>
<img src={api.photoUrl(big)} style={{maxWidth:'100%',maxHeight:'100%',borderRadius:8}} alt=""/>
</div>}
<div className="photo-grid">
{photos.map(p=>(
<div key={p.id} style={{position:'relative'}}>
<img src={api.photoUrl(p.id)} className="photo-thumb" onClick={()=>setBig(p.id)} alt=""/>
<div style={{position:'absolute',bottom:4,left:4,fontSize:9,background:'rgba(0,0,0,0.6)',
color:'white',padding:'1px 4px',borderRadius:3}}>
{p.date?.slice(0,10)||p.created?.slice(0,10)}
</div>
</div>
))}
</div>
</>
)
}
// Main
const TABS = [
{ id:'body', label:'⚖️ Körper' },
{ id:'nutrition', label:'🍽️ Ernährung' },
{ id:'activity', label:'🏋️ Aktivität' },
{ id:'correlation', label:'🔗 Korrelation' },
{ id:'photos', label:'📷 Fotos' },
]
export default function History() {
const location = useLocation?.() || {}
const [tab, setTab] = useState((location.state?.tab)||'body')
const [weights, setWeights] = useState([])
const [calipers, setCalipers] = useState([])
const [circs, setCircs] = useState([])
const [nutrition, setNutrition] = useState([])
const [activities, setActivities] = useState([])
const [corrData, setCorrData] = useState([])
const [insights, setInsights] = useState([])
const [profile, setProfile] = useState(null)
const [loading, setLoading] = useState(true)
const [loadingSlug,setLoadingSlug]= useState(null)
const loadAll = () => Promise.all([
api.listWeight(365), api.listCaliper(), api.listCirc(),
api.listNutrition(90), api.listActivity(200),
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
]).then(([w,ca,ci,n,a,corr,ins,p])=>{
setWeights(w); setCalipers(ca); setCircs(ci)
setNutrition(n); setActivities(a); setCorrData(corr)
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
setLoading(false)
})
useEffect(()=>{ loadAll() },[])
const requestInsight = async (slug) => {
setLoadingSlug(slug)
try {
const pid=localStorage.getItem('bodytrack_active_profile')||''
const r=await fetch(`/api/insights/run/${slug}`,{method:'POST',headers:pid?{'X-Profile-Id':pid}:{}})
if(!r.ok) throw new Error(await r.text())
const ins=await api.latestInsights()
setInsights(Array.isArray(ins)?ins:[])
} catch(e){ alert('KI-Fehler: '+e.message) }
finally{ setLoadingSlug(null) }
}
if(loading) return <div className="empty-state"><div className="spinner"/></div>
const sp={insights,onRequest:requestInsight,loadingSlug}
return (
<div>
<h1 className="page-title">Verlauf & Auswertung</h1>
<div style={{display:'flex',gap:6,overflowX:'auto',paddingBottom:6,marginBottom:16,
msOverflowStyle:'none',scrollbarWidth:'none'}}>
{TABS.map(t=>(
<button key={t.id} onClick={()=>setTab(t.id)}
style={{whiteSpace:'nowrap',padding:'7px 14px',borderRadius:20,flexShrink:0,
border:`1.5px solid ${tab===t.id?'var(--accent)':'var(--border2)'}`,
background:tab===t.id?'var(--accent)':'var(--surface)',
color:tab===t.id?'white':'var(--text2)',
fontFamily:'var(--font)',fontSize:13,fontWeight:500,cursor:'pointer'}}>
{t.label}
</button>
))}
</div>
{tab==='body' && <BodySection weights={weights} calipers={calipers} circs={circs} profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} {...sp}/>}
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
{tab==='photos' && <PhotoGrid/>}
</div>
)
}

View File

@ -0,0 +1,116 @@
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { ForgotPassword } from './PasswordRecovery'
export default function LoginScreen() {
const { login } = useAuth()
const [showForgot, setShowForgot] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)
if (showForgot) return (
<div style={{minHeight:'100vh',display:'flex',flexDirection:'column',
alignItems:'center',justifyContent:'center',background:'var(--bg)',padding:24}}>
<div style={{width:'100%',maxWidth:380}}>
<div style={{textAlign:'center',marginBottom:24}}>
<div style={{fontSize:28,fontWeight:800,color:'var(--accent)',letterSpacing:2}}>
Mitai <span style={{fontWeight:300}}>Jinkendo</span>
</div>
</div>
<div className="card" style={{padding:24}}>
<ForgotPassword onBack={()=>setShowForgot(false)}/>
</div>
</div>
</div>
)
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
setError('E-Mail und Passwort eingeben'); return
}
setLoading(true); setError(null)
try {
await login({ email: email.trim().toLowerCase(), pin: password })
} catch(e) {
setError(e.message || 'Ungültige E-Mail oder Passwort')
} finally { setLoading(false) }
}
return (
<div style={{minHeight:'100vh',display:'flex',flexDirection:'column',
alignItems:'center',justifyContent:'center',background:'var(--bg)',padding:24}}>
<div style={{width:'100%',maxWidth:380}}>
{/* Logo */}
<div style={{textAlign:'center',marginBottom:32}}>
<div style={{width:80,height:80,borderRadius:18,background:'#085041',
margin:'0 auto 16px',display:'flex',alignItems:'center',justifyContent:'center'}}>
<img src="/icon-192.png" style={{width:64,height:64,borderRadius:12}} alt=""/>
</div>
<div style={{fontSize:28,fontWeight:800,color:'var(--accent)',letterSpacing:2}}>
Mitai <span style={{fontWeight:300}}>Jinkendo</span>
</div>
<div style={{fontSize:11,color:'var(--text3)',letterSpacing:'0.15em',marginTop:4}}>
身体 · KÖRPER · GESUNDHEIT · VITALITÄT
</div>
</div>
{/* Login Card */}
<div className="card" style={{padding:28}}>
<div style={{fontSize:16,fontWeight:600,marginBottom:20,color:'var(--text1)'}}>
Anmelden
</div>
<div style={{marginBottom:12}}>
<label style={{fontSize:12,fontWeight:600,color:'var(--text3)',
letterSpacing:'0.05em',display:'block',marginBottom:6}}>E-MAIL</label>
<input type="email" className="form-input" placeholder="deine@email.de"
value={email} onChange={e=>setEmail(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&document.getElementById('pw-input')?.focus()}
style={{width:'100%',boxSizing:'border-box'}}
autoFocus autoComplete="email"/>
</div>
<div style={{marginBottom:20}}>
<label style={{fontSize:12,fontWeight:600,color:'var(--text3)',
letterSpacing:'0.05em',display:'block',marginBottom:6}}>PASSWORT</label>
<input id="pw-input" type="password" className="form-input"
placeholder="Passwort oder PIN"
value={password} onChange={e=>setPassword(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handleLogin()}
style={{width:'100%',boxSizing:'border-box'}}
autoComplete="current-password"/>
</div>
{error && (
<div style={{background:'rgba(216,90,48,0.1)',color:'#D85A30',fontSize:13,
padding:'10px 14px',borderRadius:8,marginBottom:16,
border:'1px solid rgba(216,90,48,0.2)'}}>
{error}
</div>
)}
<button className="btn btn-primary btn-full" onClick={handleLogin}
disabled={loading} style={{marginBottom:12}}>
{loading
? <><div className="spinner" style={{width:14,height:14}}/> Anmelden</>
: 'Anmelden'}
</button>
<button onClick={()=>setShowForgot(true)}
style={{background:'none',border:'none',cursor:'pointer',
fontSize:13,color:'var(--text3)',width:'100%',
textAlign:'center',padding:'4px 0',textDecoration:'underline'}}>
Passwort vergessen?
</button>
</div>
<div style={{textAlign:'center',marginTop:20,fontSize:11,color:'var(--text3)'}}>
人拳道 · Der menschliche Weg der Kampfkunst
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,410 @@
import { useState } from 'react'
import { ChevronRight, ChevronLeft, Check, X, BookOpen } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc'
import { CIRCUMFERENCE_POINTS, CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
import dayjs from 'dayjs'
// Circumference Wizard
function CircumWizard({ onDone, onCancel }) {
const [step, setStep] = useState(0)
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
const [values, setValues] = useState({})
const [saving, setSaving] = useState(false)
const points = CIRCUMFERENCE_POINTS
const current = points[step]
const totalSteps = points.length
const handleNext = () => {
if (step < totalSteps - 1) setStep(s => s + 1)
else handleSave()
}
const handleSave = async () => {
setSaving(true)
try {
const payload = { date }
Object.entries(values).forEach(([k,v]) => { if(v) payload[k] = parseFloat(v) })
await api.upsertCirc(payload)
onDone()
} finally { setSaving(false) }
}
const progress = ((step + 1) / totalSteps) * 100
return (
<div style={{display:'flex',flexDirection:'column',minHeight:'calc(100vh - 120px)'}}>
{/* Header */}
<div style={{marginBottom:20}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<span style={{fontSize:12,color:'var(--text3)'}}>Schritt {step+1} von {totalSteps}</span>
<button onClick={onCancel} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}>
<X size={18}/>
</button>
</div>
<div style={{height:4,background:'var(--border)',borderRadius:2}}>
<div style={{height:'100%',background:'var(--accent)',borderRadius:2,width:`${progress}%`,transition:'width 0.3s'}}/>
</div>
</div>
{/* Datum (nur erster Schritt) */}
{step === 0 && (
<div className="card" style={{marginBottom:16}}>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}}
value={date} onChange={e=>setDate(e.target.value)}/>
<span className="form-unit"/>
</div>
</div>
)}
{/* Messpunkt */}
<div className="card" style={{flex:1,marginBottom:16}}>
{/* Punkt-Indikator */}
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:16}}>
<div style={{width:40,height:40,borderRadius:'50%',background:current.color,
display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}>
<span style={{fontSize:16,fontWeight:700,color:'white'}}>{step+1}</span>
</div>
<div>
<div style={{fontSize:18,fontWeight:700}}>{current.label}</div>
<div style={{fontSize:11,color:'var(--text3)'}}>Umfang messen</div>
</div>
</div>
{/* Anleitung */}
<div style={{display:'flex',flexDirection:'column',gap:10,marginBottom:20}}>
{[
['📍 Wo', current.where],
['🧍 Haltung', current.posture],
['📏 Maßband', current.how],
['💡 Tipp', current.tip],
].map(([label, text]) => (
<div key={label} style={{background:'var(--surface2)',borderRadius:8,padding:'10px 12px'}}>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:3}}>{label}</div>
<div style={{fontSize:13,color:'var(--text1)',lineHeight:1.55}}>{text}</div>
</div>
))}
</div>
{/* Eingabe */}
<div style={{display:'flex',gap:10,alignItems:'center'}}>
<input
type="number" min={10} max={200} step={0.1}
className="form-input"
style={{flex:1,fontSize:24,fontWeight:700,textAlign:'center',height:56}}
placeholder=""
value={values[current.id] || ''}
onChange={e => setValues(v => ({...v, [current.id]: e.target.value}))}
onKeyDown={e => e.key==='Enter' && handleNext()}
autoFocus
/>
<span style={{fontSize:18,color:'var(--text3)',fontWeight:500}}>cm</span>
</div>
{values[current.id] && (
<div style={{textAlign:'center',marginTop:8,fontSize:12,color:'var(--accent)'}}>
{values[current.id]} cm erfasst
</div>
)}
</div>
{/* Navigation */}
<div style={{display:'flex',gap:8}}>
<button className="btn btn-secondary" style={{flex:1}} onClick={()=>setStep(s=>s-1)} disabled={step===0}>
<ChevronLeft size={16}/> Zurück
</button>
<button className="btn btn-primary" style={{flex:2}} onClick={handleNext} disabled={saving}>
{saving ? <div className="spinner" style={{width:14,height:14}}/> :
step === totalSteps-1
? <><Check size={16}/> Speichern</>
: <>Weiter <ChevronRight size={16}/></>}
</button>
</div>
{/* Übersicht bereits erfasster Werte */}
{Object.keys(values).length > 0 && (
<div style={{marginTop:14,padding:'10px 12px',background:'var(--surface2)',borderRadius:8}}>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:6}}>Bisher erfasst:</div>
<div style={{display:'flex',flexWrap:'wrap',gap:6}}>
{points.slice(0, step+1).map(p => values[p.id] ? (
<span key={p.id} onClick={()=>setStep(points.indexOf(p))}
style={{fontSize:12,background:'var(--accent-light)',color:'var(--accent-dark)',
padding:'2px 8px',borderRadius:6,cursor:'pointer'}}>
{p.label}: {values[p.id]}
</span>
) : null)}
</div>
</div>
)}
</div>
)
}
// Caliper Wizard
function CaliperWizard({ onDone, onCancel, profile }) {
const [method, setMethod] = useState('jackson3')
const [step, setStep] = useState(-1) // -1 = method select
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
const [values, setValues] = useState({})
const [saving, setSaving] = useState(false)
const sex = profile?.sex || 'm'
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
const sfPoints = METHOD_POINTS[method]?.[sex] || []
const current = step >= 0 ? CALIPER_POINTS[sfPoints[step]] : null
const totalSteps = sfPoints.length
// Live BF calculation
const sfVals = {}
sfPoints.forEach(k => { const v=values[`sf_${k}`]; if(v) sfVals[k]=parseFloat(v) })
const bfPct = Object.keys(sfVals).length === sfPoints.length && sfPoints.length > 0
? Math.round(calcBodyFat(method, sfVals, sex, age)*10)/10 : null
const bfCat = bfPct ? getBfCategory(bfPct, sex) : null
const handleNext = () => {
if (step < totalSteps - 1) setStep(s => s+1)
else handleSave()
}
const handleSave = async () => {
setSaving(true)
try {
const payload = { date, sf_method: method }
Object.entries(values).forEach(([k,v]) => { if(v) payload[k]=parseFloat(v) })
if (bfPct) {
payload.body_fat_pct = bfPct
if (profile?.weight || values.weight) {
const w = parseFloat(profile?.weight || values.weight)
payload.lean_mass = Math.round(w*(1-bfPct/100)*10)/10
payload.fat_mass = Math.round(w*(bfPct/100)*10)/10
}
}
await api.upsertCaliper(payload)
onDone()
} finally { setSaving(false) }
}
const progress = step >= 0 ? ((step+1)/totalSteps)*100 : 0
// Method selection screen
if (step === -1) {
return (
<div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:20}}>
<h2 style={{fontSize:18,fontWeight:700,margin:0}}>Caliper-Methode</h2>
<button onClick={onCancel} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}>
<X size={18}/>
</button>
</div>
<div className="form-row" style={{marginBottom:16}}>
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}}
value={date} onChange={e=>setDate(e.target.value)}/>
<span className="form-unit"/>
</div>
<div style={{display:'flex',flexDirection:'column',gap:10,marginBottom:20}}>
{Object.entries(CALIPER_METHODS).map(([k,m]) => (
<button key={k} onClick={()=>setMethod(k)}
style={{padding:'14px 16px',borderRadius:12,border:`2px solid ${method===k?'var(--accent)':'var(--border2)'}`,
background:method===k?'var(--accent-light)':'var(--surface)',cursor:'pointer',
fontFamily:'var(--font)',textAlign:'left',transition:'all 0.15s'}}>
<div style={{fontSize:15,fontWeight:600,color:method===k?'var(--accent-dark)':'var(--text1)'}}>{m.label}</div>
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{m.points_m.length} Messpunkte</div>
</button>
))}
</div>
<div style={{padding:'10px 12px',background:'var(--warn-bg)',borderRadius:8,marginBottom:16,fontSize:12,color:'var(--warn-text)'}}>
Immer rechte Körperseite · Falte 1 cm abheben · Caliper 2 Sek. · 3× messen, Mittelwert
</div>
<button className="btn btn-primary btn-full" onClick={()=>setStep(0)}>
Weiter mit {CALIPER_METHODS[method]?.label} <ChevronRight size={16}/>
</button>
</div>
)
}
return (
<div style={{display:'flex',flexDirection:'column',minHeight:'calc(100vh - 120px)'}}>
{/* Header */}
<div style={{marginBottom:20}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<span style={{fontSize:12,color:'var(--text3)'}}>Punkt {step+1} von {totalSteps} · {CALIPER_METHODS[method]?.label}</span>
<button onClick={onCancel} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}>
<X size={18}/>
</button>
</div>
<div style={{height:4,background:'var(--border)',borderRadius:2}}>
<div style={{height:'100%',background:'#D85A30',borderRadius:2,width:`${progress}%`,transition:'width 0.3s'}}/>
</div>
</div>
{/* Live BF Preview */}
{bfPct && (
<div style={{padding:'10px 14px',background:'var(--accent-light)',borderRadius:10,marginBottom:14,
display:'flex',alignItems:'center',gap:10}}>
<div style={{fontSize:22,fontWeight:700,color:bfCat?.color||'var(--accent)'}}>{bfPct}%</div>
{bfCat && <div style={{fontSize:12,color:'var(--accent-dark)'}}>{bfCat.label}</div>}
<div style={{flex:1,fontSize:11,color:'var(--accent-dark)',textAlign:'right'}}>Körperfett (live)</div>
</div>
)}
{/* Messpunkt */}
{current && (
<div className="card" style={{flex:1,marginBottom:16}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:16}}>
<div style={{width:40,height:40,borderRadius:'50%',background:current.color,
display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}>
<span style={{fontSize:16,fontWeight:700,color:'white'}}>{step+1}</span>
</div>
<div>
<div style={{fontSize:18,fontWeight:700}}>{current.label}</div>
<div style={{fontSize:11,color:'var(--text3)'}}>Hautfalte messen</div>
</div>
</div>
<div style={{display:'flex',flexDirection:'column',gap:10,marginBottom:20}}>
{[
['📍 Wo', current.where],
['🧍 Haltung', current.posture],
['🔧 Technik', current.how],
['💡 Tipp', current.tip],
].map(([label, text]) => (
<div key={label} style={{background:'var(--surface2)',borderRadius:8,padding:'10px 12px'}}>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:3}}>{label}</div>
<div style={{fontSize:13,color:'var(--text1)',lineHeight:1.55}}>{text}</div>
</div>
))}
</div>
<div style={{display:'flex',gap:10,alignItems:'center'}}>
<input
type="number" min={2} max={80} step={0.5}
className="form-input"
style={{flex:1,fontSize:24,fontWeight:700,textAlign:'center',height:56}}
placeholder=""
value={values[`sf_${sfPoints[step]}`] || ''}
onChange={e => setValues(v => ({...v, [`sf_${sfPoints[step]}`]: e.target.value}))}
onKeyDown={e => e.key==='Enter' && handleNext()}
autoFocus
/>
<span style={{fontSize:18,color:'var(--text3)',fontWeight:500}}>mm</span>
</div>
{values[`sf_${sfPoints[step]}`] && (
<div style={{textAlign:'center',marginTop:8,fontSize:12,color:'var(--accent)'}}>
{values[`sf_${sfPoints[step]}`]} mm erfasst
</div>
)}
</div>
)}
{/* Navigation */}
<div style={{display:'flex',gap:8}}>
<button className="btn btn-secondary" style={{flex:1}}
onClick={()=>setStep(s=>s<=0?-1:s-1)}>
<ChevronLeft size={16}/> Zurück
</button>
<button className="btn btn-primary" style={{flex:2}} onClick={handleNext} disabled={saving}>
{saving ? <div className="spinner" style={{width:14,height:14}}/> :
step === totalSteps-1
? <><Check size={16}/> Speichern</>
: <>Weiter <ChevronRight size={16}/></>}
</button>
</div>
{/* Übersicht */}
{Object.keys(values).length > 0 && (
<div style={{marginTop:14,padding:'10px 12px',background:'var(--surface2)',borderRadius:8}}>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:6}}>Bisher erfasst:</div>
<div style={{display:'flex',flexWrap:'wrap',gap:6}}>
{sfPoints.slice(0,step+1).map((k,idx) => values[`sf_${k}`] ? (
<span key={k} onClick={()=>setStep(idx)}
style={{fontSize:12,background:'#D85A3022',color:'#D85A30',
padding:'2px 8px',borderRadius:6,cursor:'pointer'}}>
{CALIPER_POINTS[k]?.label}: {values[`sf_${k}`]}mm
</span>
) : null)}
</div>
</div>
)}
</div>
)
}
// Main Wizard Page
export default function MeasureWizard() {
const [mode, setMode] = useState(null) // null | 'circum' | 'caliper'
const [done, setDone] = useState(false)
const [profile, setProfile] = useState(null)
const nav = useNavigate()
useState(() => { api.getProfile().then(setProfile) })
if (done) {
return (
<div style={{display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',
minHeight:'60vh',gap:16,textAlign:'center'}}>
<div style={{fontSize:48}}></div>
<h2 style={{fontSize:20,fontWeight:700}}>Gespeichert!</h2>
<p style={{color:'var(--text2)',fontSize:14}}>Deine Messung wurde erfolgreich gespeichert.</p>
<div style={{display:'flex',gap:8}}>
<button className="btn btn-primary" onClick={()=>{ setDone(false); setMode(null) }}>
Weitere Messung
</button>
<button className="btn btn-secondary" onClick={()=>nav('/')}>
Zum Dashboard
</button>
</div>
</div>
)
}
if (mode === 'circum') return <CircumWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)}/>
if (mode === 'caliper') return <CaliperWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)} profile={profile}/>
return (
<div>
<h1 className="page-title">Assistent</h1>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:20,lineHeight:1.6}}>
Der Assistent führt dich Schritt für Schritt durch die Messung mit Anleitung für jeden Messpunkt.
</p>
<div style={{display:'flex',flexDirection:'column',gap:12}}>
<button onClick={()=>setMode('circum')}
style={{padding:'20px 16px',borderRadius:14,border:'1.5px solid var(--border2)',
background:'var(--surface)',cursor:'pointer',fontFamily:'var(--font)',textAlign:'left',
display:'flex',alignItems:'center',gap:14}}>
<div style={{fontSize:32}}>📏</div>
<div>
<div style={{fontSize:16,fontWeight:600}}>Umfänge messen</div>
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
8 Messpunkte · Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Oberarm
</div>
</div>
<ChevronRight size={20} style={{marginLeft:'auto',color:'var(--text3)'}}/>
</button>
<button onClick={()=>setMode('caliper')}
style={{padding:'20px 16px',borderRadius:14,border:'1.5px solid var(--border2)',
background:'var(--surface)',cursor:'pointer',fontFamily:'var(--font)',textAlign:'left',
display:'flex',alignItems:'center',gap:14}}>
<div style={{fontSize:32}}>📐</div>
<div>
<div style={{fontSize:16,fontWeight:600}}>Caliper Körperfett</div>
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
39 Messpunkte · Jackson/Pollock, Durnin oder Parrillo
</div>
</div>
<ChevronRight size={20} style={{marginLeft:'auto',color:'var(--text3)'}}/>
</button>
<div style={{marginTop:8,padding:'12px 14px',background:'var(--surface2)',borderRadius:10,
fontSize:12,color:'var(--text3)',lineHeight:1.6}}>
💡 Für schnelle Einzeleingaben oder Bearbeitung bestehender Werte nutze die direkten Screens
unter <strong>Gewicht</strong>, <strong>Umfänge</strong> und <strong>Caliper</strong>.
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,215 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Camera, CheckCircle, ChevronDown, ChevronUp, BookOpen } from 'lucide-react'
import { api } from '../utils/api'
import { calcBodyFat, METHOD_POINTS } from '../utils/calc'
import { CIRCUMFERENCE_POINTS, CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
import dayjs from 'dayjs'
function Section({ title, open, onToggle, children, hint }) {
return (
<div className="card section-gap">
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',cursor:'pointer'}} onClick={onToggle}>
<div>
<div className="card-title" style={{margin:0}}>{title}</div>
{hint && !open && <div style={{fontSize:11,color:'var(--text3)',marginTop:2}}>{hint}</div>}
</div>
{open ? <ChevronUp size={16} color="var(--text3)"/> : <ChevronDown size={16} color="var(--text3)"/>}
</div>
{open && <div style={{marginTop:12}}>{children}</div>}
</div>
)
}
function NumInput({ label, sub, value, onChange, unit, min, max, step=0.1, guideText }) {
return (
<div className="form-row">
<label className="form-label">
{label}
{sub && <span className="form-sub">{sub}</span>}
{guideText && <span className="form-sub" style={{color:'var(--accent)',fontStyle:'normal'}}>📍 {guideText}</span>}
</label>
<input className="form-input" type="number" min={min} max={max} step={step}
value={value??''} placeholder=""
onChange={e => onChange(e.target.value==='' ? null : parseFloat(e.target.value))}/>
<span className="form-unit">{unit}</span>
</div>
)
}
export default function NewMeasurement() {
const { id } = useParams()
const nav = useNavigate()
const fileRef = useRef()
const [profile, setProfile] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [photoPreview, setPhotoPreview] = useState(null)
const [photoFile, setPhotoFile] = useState(null)
const [openSections, setOpenSections] = useState({ basic:true, circum:true, caliper:false, notes:false })
const isEdit = !!id
const [form, setForm] = useState({
date: dayjs().format('YYYY-MM-DD'),
weight: null,
c_neck:null, c_chest:null, c_waist:null, c_belly:null,
c_hip:null, c_thigh:null, c_calf:null, c_arm:null,
sf_method:'jackson3',
sf_chest:null, sf_axilla:null, sf_triceps:null, sf_subscap:null,
sf_suprailiac:null, sf_abdomen:null, sf_thigh:null,
sf_calf_med:null, sf_lowerback:null, sf_biceps:null,
notes:''
})
useEffect(() => {
api.getProfile().then(setProfile)
if (id) {
api.getMeasurement(id).then(m => {
setForm(f => ({...f, ...m}))
})
}
}, [id])
const toggle = s => setOpenSections(o => ({...o,[s]:!o[s]}))
const set = (k,v) => setForm(f => ({...f,[k]:v}))
const sex = profile?.sex || 'm'
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
const height = profile?.height || 178
const weight = form.weight || 80
const sfPoints = METHOD_POINTS[form.sf_method]?.[sex] || []
const sfVals = {}
sfPoints.forEach(k => { if (form[`sf_${k}`]) sfVals[k] = form[`sf_${k}`] })
const bfPct = Object.keys(sfVals).length===sfPoints.length
? Math.round(calcBodyFat(form.sf_method, sfVals, sex, age)*10)/10 : null
const handlePhoto = e => {
const file = e.target.files[0]; if(!file) return
setPhotoFile(file); setPhotoPreview(URL.createObjectURL(file))
}
const handleSave = async () => {
setSaving(true)
try {
const payload = {...form}
if (bfPct) {
payload.body_fat_pct = bfPct
payload.lean_mass = Math.round(weight*(1-bfPct/100)*10)/10
payload.fat_mass = Math.round(weight*(bfPct/100)*10)/10
}
let mid = id
if (id) { await api.updateMeasurement(id, payload) }
else { const r = await api.createMeasurement(payload); mid = r.id }
if (photoFile && mid) {
const pr = await api.uploadPhoto(photoFile, mid)
await api.updateMeasurement(mid, {photo_id: pr.id})
}
setSaved(true); setTimeout(()=>nav('/'), 1200)
} catch(e) { alert('Fehler beim Speichern: '+e.message) }
finally { setSaving(false) }
}
return (
<div>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
<h1 className="page-title" style={{margin:0}}>{isEdit ? 'Messung bearbeiten' : 'Neue Messung'}</h1>
<button className="btn btn-secondary" style={{padding:'6px 10px',fontSize:12}} onClick={()=>nav('/guide')}>
<BookOpen size={14}/> Anleitung
</button>
</div>
{/* Grunddaten */}
<Section title="Grunddaten" open={openSections.basic} onToggle={()=>toggle('basic')}>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}}
value={form.date} onChange={e=>set('date',e.target.value)}/>
<span className="form-unit"/>
</div>
<NumInput label="Gewicht" value={form.weight} onChange={v=>set('weight',v)} unit="kg" min={30} max={250}/>
</Section>
{/* Umfänge */}
<Section title="Umfänge" open={openSections.circum} onToggle={()=>toggle('circum')}
hint="Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Oberarm">
{CIRCUMFERENCE_POINTS.map(p => (
<NumInput key={p.id} label={p.label}
guideText={p.where.length > 50 ? p.where.substring(0,48)+'…' : p.where}
value={form[p.id]} onChange={v=>set(p.id,v)} unit="cm" min={10} max={200}/>
))}
</Section>
{/* Caliper */}
<Section title="Caliper Körperfett" open={openSections.caliper} onToggle={()=>toggle('caliper')}
hint="Optional für präzise Körperfett-Messung">
<div className="form-row">
<label className="form-label">Methode</label>
<select className="form-select" style={{width:'auto'}}
value={form.sf_method} onChange={e=>set('sf_method',e.target.value)}>
{Object.entries(CALIPER_METHODS).map(([k,m]) => (
<option key={k} value={k}>{m.label}</option>
))}
</select>
</div>
<div style={{padding:'8px 12px',background:'var(--warn-bg)',borderRadius:8,marginBottom:10,fontSize:12,color:'var(--warn-text)'}}>
Immer rechte Körperseite · Falte 1 cm abheben · 3× messen, Mittelwert nehmen
<span style={{marginLeft:8,cursor:'pointer',textDecoration:'underline'}} onClick={()=>nav('/guide')}> Anleitung</span>
</div>
{sfPoints.map(k => {
const p = CALIPER_POINTS[k]
return p ? (
<NumInput key={k} label={p.label}
guideText={p.where.length > 50 ? p.where.substring(0,48)+'…' : p.where}
value={form[`sf_${k}`]} onChange={v=>set(`sf_${k}`,v)} unit="mm" min={2} max={80}/>
) : null
})}
{bfPct !== null && (
<div style={{marginTop:12,padding:'12px',background:'var(--accent-light)',borderRadius:8}}>
<div style={{fontSize:24,fontWeight:700,color:'var(--accent)'}}>{bfPct} %</div>
<div style={{fontSize:12,color:'var(--accent-dark)'}}>Körperfett ({CALIPER_METHODS[form.sf_method]?.label})</div>
{form.weight && <div style={{fontSize:12,color:'var(--accent-dark)',marginTop:2}}>
Magermasse: {Math.round(form.weight*(1-bfPct/100)*10)/10} kg ·{' '}
Fettmasse: {Math.round(form.weight*(bfPct/100)*10)/10} kg
</div>}
</div>
)}
</Section>
{/* Foto */}
<div className="card section-gap">
<div className="card-title">Fortschrittsfoto</div>
<input ref={fileRef} type="file" accept="image/*" capture="environment"
style={{display:'none'}} onChange={handlePhoto}/>
{photoPreview && <img src={photoPreview} style={{width:'100%',borderRadius:8,marginBottom:10}} alt="preview"/>}
<button className="btn btn-secondary btn-full" onClick={()=>fileRef.current.click()}>
<Camera size={16}/> {photoPreview ? 'Foto ändern' : 'Foto aufnehmen / auswählen'}
</button>
</div>
{/* Notizen */}
<Section title="Notizen" open={openSections.notes} onToggle={()=>toggle('notes')}>
<textarea style={{width:'100%',minHeight:80,padding:10,fontFamily:'var(--font)',fontSize:14,
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
color:'var(--text1)',resize:'vertical'}}
placeholder="Besonderheiten, Befinden, Trainingszustand…"
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
</Section>
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving||saved} style={{marginTop:4}}>
{saved ? <><CheckCircle size={16}/> Gespeichert!</>
: saving ? <><div className="spinner" style={{width:16,height:16}}/> Speichern</>
: isEdit ? 'Änderungen speichern' : 'Messung speichern'}
</button>
{isEdit && (
<button className="btn btn-secondary btn-full" onClick={()=>nav('/')} style={{marginTop:8}}>
Abbrechen
</button>
)}
</div>
)
}

View File

@ -0,0 +1,434 @@
import { useState, useEffect, useRef } from 'react'
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
} from 'recharts'
import { api as nutritionApi } from '../utils/api'
import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
dayjs.extend(isoWeek)
// Helpers
const KCAL_PER_KG_FAT = 7700
function rollingAvg(arr, key, window=7) {
return arr.map((d,i) => {
const slice = arr.slice(Math.max(0,i-window+1),i+1).map(x=>x[key]).filter(v=>v!=null)
return slice.length ? {...d, [`${key}_avg`]: Math.round(slice.reduce((a,b)=>a+b,0)/slice.length*10)/10} : d
})
}
// Import Panel
function ImportPanel({ onImported }) {
const fileRef = useRef()
const [status, setStatus] = useState(null)
const [error, setError] = useState(null)
const [dragging,setDragging]= useState(false)
const [tab, setTab] = useState('file') // 'file' | 'paste'
const [pasteText, setPasteText] = useState('')
const runImport = async (file) => {
setStatus('loading'); setError(null)
try {
const result = await nutritionApi.importCsv(file)
if (result.days_imported === undefined) throw new Error(JSON.stringify(result))
setStatus(result)
onImported()
} catch(err) {
setError('Import fehlgeschlagen: ' + err.message)
setStatus(null)
}
}
const handleFile = async e => {
const file = e.target.files[0]; if (!file) return
await runImport(file)
e.target.value = ''
}
const handleDrop = async e => {
e.preventDefault(); setDragging(false)
const file = e.dataTransfer.files[0]
if (!file) return
await runImport(file)
}
const handlePasteImport = async () => {
if (!pasteText.trim()) return
const blob = new Blob([pasteText], { type: 'text/csv' })
const file = new File([blob], 'paste.csv', { type: 'text/csv' })
await runImport(file)
}
return (
<div className="card section-gap">
<div className="card-title">📥 FDDB CSV Import</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
In FDDB: <strong>Mein Tagebuch Exportieren CSV</strong> dann hier importieren.
</p>
{/* Tab switcher */}
<div style={{display:'flex',gap:6,marginBottom:12}}>
{[['file','📁 Datei / Drag & Drop'],['paste','📋 Text einfügen']].map(([k,l])=>(
<button key={k} onClick={()=>setTab(k)}
style={{flex:1,padding:'7px 10px',borderRadius:8,border:`1.5px solid ${tab===k?'var(--accent)':'var(--border2)'}`,
background:tab===k?'var(--accent-light)':'var(--surface)',
color:tab===k?'var(--accent-dark)':'var(--text2)',
fontFamily:'var(--font)',fontSize:12,fontWeight:500,cursor:'pointer'}}>
{l}
</button>
))}
</div>
{tab==='file' && (
<>
{/* Drag & Drop Zone */}
<div
onDragOver={e=>{e.preventDefault();setDragging(true)}}
onDragLeave={()=>setDragging(false)}
onDrop={handleDrop}
onClick={()=>fileRef.current.click()}
style={{
border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,
borderRadius:10, padding:'24px 16px', textAlign:'center',
background: dragging?'var(--accent-light)':'var(--surface2)',
cursor:'pointer', transition:'all 0.15s',
}}>
<Upload size={28} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:8}}/>
<div style={{fontSize:14,fontWeight:500,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
{dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'}
</div>
<div style={{fontSize:11,color:'var(--text3)',marginTop:4}}>.csv Dateien</div>
</div>
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} onChange={handleFile}/>
</>
)}
{tab==='paste' && (
<>
<textarea
style={{width:'100%',minHeight:120,padding:10,fontFamily:'monospace',fontSize:11,
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
color:'var(--text1)',resize:'vertical',boxSizing:'border-box'}}
placeholder="datum_tag_monat_jahr_stunde_minute;bezeichnung;&#10;13.03.2026 21:54;50 g Hähnchen;..."
value={pasteText}
onChange={e=>setPasteText(e.target.value)}
/>
<button className="btn btn-primary btn-full" style={{marginTop:8}}
onClick={handlePasteImport} disabled={status==='loading'||!pasteText.trim()}>
{status==='loading'
? <><div className="spinner" style={{width:14,height:14}}/> Importiere</>
: <><Upload size={15}/> CSV-Text importieren</>}
</button>
</>
)}
{status==='loading' && (
<div style={{marginTop:10,display:'flex',alignItems:'center',gap:8,fontSize:13,color:'var(--text2)'}}>
<div className="spinner" style={{width:16,height:16}}/> Importiere
</div>
)}
{error && (
<div style={{marginTop:8,padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30'}}>
{error}
</div>
)}
{status && status !== 'loading' && (
<div style={{marginTop:8,padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,fontSize:13,color:'var(--accent-dark)'}}>
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:4}}>
<CheckCircle size={15}/><strong>Import erfolgreich</strong>
</div>
<div>{status.days_imported} Tage importiert · {status.rows_parsed} Einträge verarbeitet</div>
{status.date_range?.from && (
<div style={{fontSize:11,marginTop:2}}>
{dayjs(status.date_range.from).format('DD.MM.YYYY')} {dayjs(status.date_range.to).format('DD.MM.YYYY')}
</div>
)}
</div>
)}
</div>
)
}
// Overview Cards
function OverviewCards({ data }) {
if (!data.length) return null
const last7 = data.filter(d=>d.kcal).slice(-7)
if (!last7.length) return null
const avg = key => Math.round(last7.map(d=>d[key]||0).reduce((a,b)=>a+b,0)/last7.length)
const kcal = avg('kcal'), prot = avg('protein_g'), fat = avg('fat_g'), carbs = avg('carbs_g')
const total_g = prot + fat + carbs
return (
<div className="card section-gap">
<div className="card-title">Ø letzte 7 Tage</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8}}>
{[
['🔥 Kalorien', kcal, 'kcal', '#EF9F27'],
['🥩 Protein', prot, 'g', '#1D9E75'],
['🫙 Fett', fat, 'g', '#378ADD'],
['🍞 Kohlenhydrate', carbs, 'g', '#D4537E'],
].map(([label, val, unit, color]) => (
<div key={label} style={{background:'var(--surface2)',borderRadius:8,padding:'10px 12px'}}>
<div style={{fontSize:20,fontWeight:700,color}}>{val}<span style={{fontSize:12,color:'var(--text3)',marginLeft:2}}>{unit}</span></div>
<div style={{fontSize:11,color:'var(--text3)'}}>{label}</div>
{unit==='g' && total_g>0 && <div style={{fontSize:10,color:'var(--text3)'}}>{Math.round(val/total_g*100)}% der Makros</div>}
</div>
))}
</div>
<div style={{marginTop:8,padding:'6px 10px',background:'var(--surface2)',borderRadius:8,fontSize:12,color:'var(--text3)'}}>
<Info size={11} style={{marginRight:4,verticalAlign:'middle'}}/>
Protein-Ziel: 1,62,2 g/kg Körpergewicht für Muskelaufbau
</div>
</div>
)
}
// Chart: Kalorien vs Gewicht
function CaloriesVsWeight({ data }) {
const filtered = data.filter(d => d.kcal && d.weight)
const withAvg = rollingAvg(filtered.map(d=>({...d,date:dayjs(d.date).format('DD.MM')})), 'kcal')
if (filtered.length < 3) return (
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:13}}>
Zu wenig gemeinsame Daten (Gewicht + Kalorien am selben Tag nötig)
</div>
)
return (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={withAvg} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(withAvg.length/6)-1)}/>
<YAxis yAxisId="kcal" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<YAxis yAxisId="weight" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`, n==='kcal_avg'?'Ø 7T Kalorien':n==='weight'?'Gewicht':'Kalorien']}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal" stroke="#EF9F2744" strokeWidth={1} dot={false}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} name="kcal_avg"/>
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2} dot={{r:3,fill:'#378ADD'}} name="weight"/>
</LineChart>
</ResponsiveContainer>
)
}
// Chart: Protein vs Magermasse
function ProteinVsLeanMass({ data }) {
const filtered = data.filter(d => d.protein_g && d.lean_mass)
if (filtered.length < 3) return (
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:13}}>
Noch zu wenig Messungen mit Magermasse-Werten für diese Auswertung
</div>
)
const chartData = filtered.map(d=>({
date: dayjs(d.date).format('DD.MM'),
protein_g: d.protein_g,
lean_mass: d.lean_mass,
}))
return (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
<YAxis yAxisId="prot" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<YAxis yAxisId="lean" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${Math.round(v)} ${n==='lean_mass'?'kg':'g'}`, n==='protein_g'?'Protein':'Magermasse']}/>
<Legend wrapperStyle={{fontSize:11}}/>
<Line yAxisId="prot" type="monotone" dataKey="protein_g" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein_g"/>
<Line yAxisId="lean" type="monotone" dataKey="lean_mass" stroke="#7F77DD" strokeWidth={2} dot={{r:4,fill:'#7F77DD'}} name="lean_mass"/>
</LineChart>
</ResponsiveContainer>
)
}
// Chart: Makro-Verteilung pro Woche (Balken)
function WeeklyMacros({ weekly }) {
if (!weekly.length) return null
const data = weekly.slice(-12).map(w => ({
week: w.week.replace(/\d{4}-/,''),
Protein: w.protein_g,
Fett: w.fat_g,
'Kohlenhydrate': w.carbs_g,
kcal: Math.round(w.kcal),
}))
return (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="week" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${Math.round(v)} g`, n]}/>
<Legend wrapperStyle={{fontSize:11}}/>
<Bar dataKey="Protein" stackId="a" fill="#1D9E75"/>
<Bar dataKey="Fett" stackId="a" fill="#378ADD"/>
<Bar dataKey="Kohlenhydrate" stackId="a" fill="#D4537E" radius={[3,3,0,0]}/>
</BarChart>
</ResponsiveContainer>
)
}
// Chart: Kaloriendefizit/-überschuss Trend
function CalorieBalance({ data, profile }) {
// Rough TDEE estimate (Mifflin-St Jeor + activity 1.55)
const sex = profile?.sex || 'm'
const height = profile?.height || 178
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
// Use average weight from data
const weights = data.filter(d=>d.weight).map(d=>d.weight)
const avgWeight = weights.length ? weights.reduce((a,b)=>a+b)/weights.length : 80
const bmr = sex==='m'
? 10*avgWeight + 6.25*height - 5*age + 5
: 10*avgWeight + 6.25*height - 5*age - 161
const tdee = Math.round(bmr * 1.55)
const filtered = data.filter(d=>d.kcal)
const withAvg = rollingAvg(filtered.map(d=>({
...d,
date: dayjs(d.date).format('DD.MM'),
balance: Math.round(d.kcal - tdee),
})), 'balance')
if (filtered.length < 5) return (
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:13}}>
Mehr Kalorieneinträge nötig für diese Auswertung
</div>
)
return (
<>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:6,textAlign:'center'}}>
Geschätzter TDEE: <strong>{tdee} kcal</strong> · Ø Gewicht: {Math.round(avgWeight*10)/10} kg
<span style={{marginLeft:6,opacity:0.7}}>(Mifflin-St Jeor × 1,55)</span>
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={withAvg} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(withAvg.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v>0?'+':''}${v} kcal`, n==='balance_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/>
<Line type="monotone" dataKey="balance" stroke="#EF9F2744" strokeWidth={1} dot={false}/>
<Line type="monotone" dataKey="balance_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_avg"/>
</LineChart>
</ResponsiveContainer>
</>
)
}
// Main Page
export default function NutritionPage() {
const [tab, setTab] = useState('overview')
const [corrData, setCorr] = useState([])
const [weekly, setWeekly] = useState([])
const [profile, setProf] = useState(null)
const [loading, setLoad] = useState(true)
const [hasData, setHasData]= useState(false)
const load = async () => {
setLoad(true)
try {
const [corr, wkly, prof] = await Promise.all([
nutritionApi.nutritionCorrelations(),
nutritionApi.nutritionWeekly(16),
fetch('/api/profile').then(r=>r.json()),
])
setCorr(Array.isArray(corr)?corr:[])
setWeekly(Array.isArray(wkly)?wkly:[])
setProf(prof)
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
} catch(e) { console.error('load error:', e) }
finally { setLoad(false) }
}
useEffect(() => { load() }, [])
return (
<div>
<h1 className="page-title">Ernährung</h1>
<ImportPanel onImported={load}/>
{loading && <div className="empty-state"><div className="spinner"/></div>}
{!loading && !hasData && (
<div className="empty-state">
<h3>Noch keine Ernährungsdaten</h3>
<p>Importiere deinen FDDB-Export oben um Auswertungen zu sehen.</p>
</div>
)}
{!loading && hasData && (
<>
<OverviewCards data={corrData}/>
<div className="tabs section-gap" style={{overflowX:'auto',flexWrap:'nowrap'}}>
<button className={'tab'+(tab==='overview'?' active':'')} onClick={()=>setTab('overview')}>Übersicht</button>
<button className={'tab'+(tab==='weight'?' active':'')} onClick={()=>setTab('weight')}>Kcal vs. Gewicht</button>
<button className={'tab'+(tab==='protein'?' active':'')} onClick={()=>setTab('protein')}>Protein vs. Mager</button>
<button className={'tab'+(tab==='balance'?' active':'')} onClick={()=>setTab('balance')}>Bilanz</button>
</div>
{tab==='overview' && (
<div className="card section-gap">
<div className="card-title">Makro-Verteilung pro Woche (Ø g/Tag)</div>
<WeeklyMacros weekly={weekly}/>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:8,fontSize:11,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:10,height:10,background:'#1D9E75',borderRadius:2,marginRight:4}}/>Protein</span>
<span><span style={{display:'inline-block',width:10,height:10,background:'#378ADD',borderRadius:2,marginRight:4}}/>Fett</span>
<span><span style={{display:'inline-block',width:10,height:10,background:'#D4537E',borderRadius:2,marginRight:4}}/>Kohlenhydrate</span>
</div>
</div>
)}
{tab==='weight' && (
<div className="card section-gap">
<div className="card-title">Kalorien vs. Gewichtsverlauf</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
<span style={{color:'#EF9F27',fontWeight:600}}> Kalorien (Ø 7T)</span>
{' '}
<span style={{color:'#378ADD',fontWeight:600}}> Gewicht</span>
</div>
<CaloriesVsWeight data={corrData}/>
<div style={{marginTop:10,fontSize:12,color:'var(--text3)',lineHeight:1.6}}>
💡 Kalorien steigen Gewicht steigt mit ~12 Wochen Verzögerung.<br/>
7-Tage-Glättung filtert tägliche Schwankungen heraus.
</div>
</div>
)}
{tab==='protein' && (
<div className="card section-gap">
<div className="card-title">Protein vs. Magermasse</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
<span style={{color:'#1D9E75',fontWeight:600}}> Protein g/Tag</span>
{' '}
<span style={{color:'#7F77DD',fontWeight:600}}> Magermasse kg</span>
</div>
<ProteinVsLeanMass data={corrData}/>
<div style={{marginTop:10,fontSize:12,color:'var(--text3)',lineHeight:1.6}}>
💡 Magermasse-Punkte sind die tatsächlichen Caliper-Messungen.<br/>
Ziel: 1,62,2 g Protein pro kg Körpergewicht täglich.
</div>
</div>
)}
{tab==='balance' && (
<div className="card section-gap">
<div className="card-title">Kaloriendefizit / -überschuss</div>
<CalorieBalance data={corrData} profile={profile}/>
<div style={{marginTop:10,fontSize:12,color:'var(--text3)',lineHeight:1.6}}>
💡 Über 0 = Überschuss (Aufbau), unter 0 = Defizit (Abbau).<br/>
~500 kcal Defizit = ~0,5 kg Fettabbau pro Woche theoretisch.
</div>
</div>
)}
</>
)}
</div>
)
}

View File

@ -0,0 +1,126 @@
import { useState } from 'react'
import { Check, ArrowLeft } from 'lucide-react'
export function ForgotPassword({ onBack }) {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const handleSubmit = async () => {
if (!email.trim()) return setError('E-Mail eingeben')
setLoading(true); setError(null)
try {
const r = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim() })
})
if (!r.ok) throw new Error(await r.text())
setSent(true)
} catch(e) {
setError(e.message)
} finally { setLoading(false) }
}
if (sent) return (
<div style={{textAlign:'center',padding:'20px 0'}}>
<div style={{fontSize:48,marginBottom:12}}>📧</div>
<div style={{fontSize:16,fontWeight:700,marginBottom:8}}>E-Mail gesendet</div>
<div style={{fontSize:13,color:'var(--text2)',lineHeight:1.6,marginBottom:20}}>
Falls ein Konto mit dieser E-Mail existiert, hast du einen Link zum Zurücksetzen erhalten.
Bitte prüfe auch deinen Spam-Ordner.
</div>
<button className="btn btn-secondary btn-full" onClick={onBack}>
<ArrowLeft size={14}/> Zurück zum Login
</button>
</div>
)
return (
<div>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:20}}>
<button onClick={onBack}
style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)',fontSize:20,padding:4}}></button>
<div>
<div style={{fontWeight:600,fontSize:16}}>Passwort vergessen</div>
<div style={{fontSize:12,color:'var(--text3)'}}>Recovery-Link per E-Mail</div>
</div>
</div>
<div style={{fontSize:13,color:'var(--text2)',marginBottom:16,lineHeight:1.6}}>
Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts.
</div>
<input type="email" className="form-input" placeholder="deine@email.de"
value={email} onChange={e=>setEmail(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handleSubmit()}
style={{width:'100%',marginBottom:12,boxSizing:'border-box'}} autoFocus/>
{error && <div style={{color:'#D85A30',fontSize:12,marginBottom:10}}>{error}</div>}
<button className="btn btn-primary btn-full" onClick={handleSubmit} disabled={loading}>
{loading
? <><div className="spinner" style={{width:14,height:14}}/> Senden</>
: <>Recovery-Link senden</>}
</button>
</div>
)
}
export function ResetPassword({ token, onDone }) {
const [pin, setPin] = useState('')
const [pin2, setPin2] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [done, setDone] = useState(false)
const handleReset = async () => {
if (pin.length < 4) return setError('Mind. 4 Zeichen')
if (pin !== pin2) return setError('Eingaben stimmen nicht überein')
setLoading(true); setError(null)
try {
const r = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, pin })
})
if (!r.ok) {
const err = await r.json()
throw new Error(err.detail || 'Fehler')
}
setDone(true)
} catch(e) {
setError(e.message)
} finally { setLoading(false) }
}
if (done) return (
<div style={{textAlign:'center',padding:'20px 0'}}>
<div style={{fontSize:48,marginBottom:12}}></div>
<div style={{fontSize:16,fontWeight:700,marginBottom:8}}>Passwort geändert</div>
<div style={{fontSize:13,color:'var(--text2)',marginBottom:20}}>
Du kannst dich jetzt mit deinem neuen Passwort einloggen.
</div>
<button className="btn btn-primary btn-full" onClick={onDone}>
Zum Login
</button>
</div>
)
return (
<div>
<div style={{fontSize:16,fontWeight:700,marginBottom:6}}>Neues Passwort setzen</div>
<div style={{fontSize:13,color:'var(--text2)',marginBottom:16}}>
Wähle ein neues Passwort oder eine neue PIN (mind. 4 Zeichen).
</div>
<input type="password" className="form-input" placeholder="Neues Passwort"
value={pin} onChange={e=>setPin(e.target.value)}
style={{width:'100%',marginBottom:10,boxSizing:'border-box'}} autoFocus/>
<input type="password" className="form-input" placeholder="Wiederholen"
value={pin2} onChange={e=>setPin2(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handleReset()}
style={{width:'100%',marginBottom:12,boxSizing:'border-box'}}/>
{error && <div style={{color:'#D85A30',fontSize:12,marginBottom:10}}>{error}</div>}
<button className="btn btn-primary btn-full" onClick={handleReset} disabled={loading}>
{loading ? <><div className="spinner" style={{width:14,height:14}}/> Speichern</> : 'Passwort setzen'}
</button>
</div>
)
}

View File

@ -0,0 +1,124 @@
import { useState } from 'react'
import { Plus, Check } from 'lucide-react'
import { useProfile } from '../context/ProfileContext'
import { api } from '../utils/api'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
const AVATARS = ['👤','👦','👧','👨','👩','🧔','👴','👵','🧒','🧑']
function Avatar({ profile, size=48, onClick, selected }) {
const initials = profile.name.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2)
return (
<div onClick={onClick} style={{
width:size, height:size, borderRadius:'50%',
background: profile.avatar_color || '#1D9E75',
display:'flex', alignItems:'center', justifyContent:'center',
fontSize: size*0.38, fontWeight:700, color:'white', cursor:onClick?'pointer':'default',
border: selected ? '3px solid white' : '3px solid transparent',
boxShadow: selected ? `0 0 0 3px ${profile.avatar_color}` : 'none',
transition:'all 0.15s', flexShrink:0,
}}>
{initials}
</div>
)
}
export { Avatar }
export default function ProfileSelect() {
const { profiles, setActiveProfile, refreshProfiles } = useProfile()
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(COLORS[0])
const [saving, setSaving] = useState(false)
const handleCreate = async () => {
if (!newName.trim()) return
setSaving(true)
try {
const p = await api.createProfile({ name: newName.trim(), avatar_color: newColor })
// Refresh profile list first, then set active
await refreshProfiles()
setActiveProfile(p)
setCreating(false)
setNewName('')
} catch(e) {
console.error('Create profile error:', e)
} finally { setSaving(false) }
}
return (
<div style={{
minHeight:'100vh', display:'flex', flexDirection:'column',
alignItems:'center', justifyContent:'center',
background:'var(--bg)', padding:24,
}}>
<div style={{marginBottom:24,textAlign:'center'}}>
<div style={{fontSize:32,fontWeight:800,color:'var(--accent)',letterSpacing:'-0.5px'}}>BodyTrack</div>
<div style={{fontSize:15,color:'var(--text3)',marginTop:4}}>Wer bist du?</div>
</div>
<div style={{width:'100%',maxWidth:360}}>
{profiles.map(p => (
<div key={p.id} onClick={()=>setActiveProfile(p)}
style={{display:'flex',alignItems:'center',gap:14,padding:'14px 16px',
background:'var(--surface)',borderRadius:14,marginBottom:10,cursor:'pointer',
border:'1.5px solid var(--border)',transition:'all 0.15s'}}
onMouseEnter={e=>e.currentTarget.style.borderColor='var(--accent)'}
onMouseLeave={e=>e.currentTarget.style.borderColor='var(--border)'}>
<Avatar profile={p} size={52}/>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:16}}>{p.name}</div>
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
{p.sex==='m'?'Männlich':'Weiblich'}{p.height?` · ${p.height} cm`:''}
{p.goal_weight?` · Ziel: ${p.goal_weight} kg`:''}
</div>
</div>
<div style={{fontSize:20,color:'var(--text3)'}}></div>
</div>
))}
{!creating ? (
<button onClick={()=>setCreating(true)}
style={{width:'100%',padding:'13px 16px',borderRadius:14,
border:'1.5px dashed var(--border2)',background:'transparent',
color:'var(--text3)',fontSize:14,cursor:'pointer',fontFamily:'var(--font)',
display:'flex',alignItems:'center',justifyContent:'center',gap:8}}>
<Plus size={16}/> Neues Profil erstellen
</button>
) : (
<div style={{background:'var(--surface)',borderRadius:14,padding:16,border:'1.5px solid var(--accent)'}}>
<div style={{fontWeight:600,fontSize:14,marginBottom:12}}>Neues Profil</div>
<input
type="text" placeholder="Name eingeben"
value={newName} onChange={e=>setNewName(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handleCreate()}
style={{width:'100%',padding:'10px 12px',borderRadius:8,fontSize:15,fontWeight:500,
border:'1.5px solid var(--border2)',background:'var(--surface2)',
color:'var(--text1)',fontFamily:'var(--font)',boxSizing:'border-box',marginBottom:12}}
autoFocus/>
<div style={{marginBottom:12}}>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:8}}>Farbe wählen</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
{COLORS.map(c=>(
<div key={c} onClick={()=>setNewColor(c)}
style={{width:32,height:32,borderRadius:'50%',background:c,cursor:'pointer',
border:`3px solid ${newColor===c?'white':'transparent'}`,
boxShadow:newColor===c?`0 0 0 2px ${c}`:'none',transition:'all 0.1s'}}/>
))}
</div>
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={handleCreate} disabled={saving||!newName.trim()}
className="btn btn-primary" style={{flex:1}}>
{saving?<div className="spinner" style={{width:14,height:14}}/>:<><Check size={14}/> Erstellen</>}
</button>
<button onClick={()=>{setCreating(false);setNewName('')}}
className="btn btn-secondary" style={{flex:1}}>Abbrechen</button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,319 @@
import { useState } from 'react'
import { Save, Download, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react'
import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext'
import { Avatar } from './ProfileSelect'
import { api } from '../utils/api'
import AdminPanel from './AdminPanel'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
function ProfileForm({ profile, onSave, onCancel, title }) {
const [form, setForm] = useState({
name: profile?.name || '',
sex: profile?.sex || 'm',
dob: profile?.dob || '',
height: profile?.height || '',
goal_weight: profile?.goal_weight || '',
goal_bf_pct: profile?.goal_bf_pct || '',
avatar_color: profile?.avatar_color || COLORS[0],
})
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div style={{background:'var(--surface2)',borderRadius:10,padding:14,marginTop:8,
border:'1.5px solid var(--accent)'}}>
{title && <div style={{fontWeight:600,fontSize:14,marginBottom:12,color:'var(--accent)'}}>{title}</div>}
<div className="form-row">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={form.name}
onChange={e=>set('name',e.target.value)} autoFocus/>
<span className="form-unit"/>
</div>
<div style={{marginBottom:12}}>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:8}}>Avatar-Farbe</div>
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<Avatar profile={{...form}} size={36}/>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{COLORS.map(c=>(
<div key={c} onClick={()=>set('avatar_color',c)}
style={{width:26,height:26,borderRadius:'50%',background:c,cursor:'pointer',
border:`3px solid ${form.avatar_color===c?'white':'transparent'}`,
boxShadow:form.avatar_color===c?`0 0 0 2px ${c}`:'none'}}/>
))}
</div>
</div>
</div>
<div className="form-row">
<label className="form-label">Geschlecht</label>
<select className="form-select" value={form.sex} onChange={e=>set('sex',e.target.value)}>
<option value="m">Männlich</option>
<option value="f">Weiblich</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Geburtsdatum</label>
<input type="date" className="form-input" style={{width:140}} value={form.dob||''}
onChange={e=>set('dob',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Größe</label>
<input type="number" className="form-input" min={100} max={250} value={form.height||''}
onChange={e=>set('height',e.target.value)}/>
<span className="form-unit">cm</span>
</div>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',textTransform:'uppercase',
letterSpacing:'0.04em',margin:'10px 0 6px'}}>Ziele (optional)</div>
<div className="form-row">
<label className="form-label">Zielgewicht</label>
<input type="number" className="form-input" min={30} max={300} step={0.1}
value={form.goal_weight||''} onChange={e=>set('goal_weight',e.target.value)} placeholder=""/>
<span className="form-unit">kg</span>
</div>
<div className="form-row">
<label className="form-label">Ziel-KF%</label>
<input type="number" className="form-input" min={3} max={50} step={0.1}
value={form.goal_bf_pct||''} onChange={e=>set('goal_bf_pct',e.target.value)} placeholder=""/>
<span className="form-unit">%</span>
</div>
<div style={{display:'flex',gap:8,marginTop:12}}>
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(form)}>
<Save size={13}/> Speichern
</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}>
<X size={13}/> Abbrechen
</button>
</div>
</div>
)
}
export default function SettingsPage() {
const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile()
const { logout, isAdmin, canExport } = useAuth()
const [adminOpen, setAdminOpen] = useState(false)
const [pinOpen, setPinOpen] = useState(false)
const [newPin, setNewPin] = useState('')
const [pinMsg, setPinMsg] = useState(null)
const handleLogout = async () => {
if (!confirm('Ausloggen?')) return
await logout()
}
const handlePinChange = async () => {
if (newPin.length < 4) return setPinMsg('Mind. 4 Zeichen')
try {
const token = localStorage.getItem('bodytrack_token')||''
const pid = localStorage.getItem('bodytrack_active_profile')||''
const r = await fetch('/api/auth/pin', {
method:'PUT',
headers:{'Content-Type':'application/json','X-Auth-Token':token,'X-Profile-Id':pid},
body: JSON.stringify({pin: newPin})
})
if (!r.ok) throw new Error('Fehler')
setNewPin(''); setPinMsg('✓ PIN geändert')
setTimeout(()=>setPinMsg(null), 2000)
} catch(e) { setPinMsg('Fehler beim Speichern') }
}
// editingId: string ID of profile being edited, or 'new' for new profile, or null
const [editingId, setEditingId] = useState(null)
const [saved, setSaved] = useState(false)
const handleSave = async (form, profileId) => {
const data = {}
if (form.name) data.name = form.name
if (form.sex) data.sex = form.sex
if (form.dob) data.dob = form.dob
if (form.height) data.height = parseFloat(form.height)
if (form.avatar_color) data.avatar_color = form.avatar_color
if (form.goal_weight) data.goal_weight = parseFloat(form.goal_weight)
if (form.goal_bf_pct) data.goal_bf_pct = parseFloat(form.goal_bf_pct)
if (profileId === 'new') {
const p = await api.createProfile({ ...data, name: form.name || 'Neues Profil' })
await refreshProfiles()
// Don't auto-switch just close the form
} else {
await api.updateProfile(profileId, data)
await refreshProfiles()
// If editing active profile, update it
if (profileId === activeProfile?.id) {
const updated = profiles.find(p => p.id === profileId)
if (updated) setActiveProfile({...updated, ...data})
}
}
setEditingId(null)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const handleDelete = async (id) => {
if (!confirm('Profil und ALLE zugehörigen Daten unwiderruflich löschen?')) return
await api.deleteProfile(id)
await refreshProfiles()
if (activeProfile?.id === id) {
const remaining = profiles.filter(p => p.id !== id)
if (remaining.length) setActiveProfile(remaining[0])
}
setEditingId(null)
}
return (
<div>
<h1 className="page-title">Einstellungen</h1>
{/* Profile list */}
<div className="card section-gap">
<div className="card-title">Profile ({profiles.length})</div>
{profiles.map(p => (
<div key={p.id}>
<div style={{display:'flex',alignItems:'center',gap:10,padding:'10px 0',
borderBottom:'1px solid var(--border)'}}>
<Avatar profile={p} size={40}/>
<div style={{flex:1}}>
<div style={{fontSize:14,fontWeight:600}}>{p.name}</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
{p.sex==='m'?'Männlich':'Weiblich'}
{p.height ? ` · ${p.height} cm` : ''}
{p.goal_weight ? ` · Ziel: ${p.goal_weight} kg` : ''}
</div>
</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
{activeProfile?.id === p.id
? <span style={{fontSize:11,color:'var(--accent)',fontWeight:600,padding:'3px 8px',
background:'var(--accent-light)',borderRadius:6}}>Aktiv</span>
: <button className="btn btn-secondary" style={{padding:'4px 10px',fontSize:12}}
onClick={handleLogout}>
Nutzer wechseln
</button>
}
<button className="btn btn-secondary" style={{padding:'4px 8px'}}
onClick={()=>setEditingId(editingId===p.id ? null : p.id)}>
<Pencil size={12}/>
</button>
{profiles.length > 1 && (
<button className="btn btn-danger" style={{padding:'4px 8px'}}
onClick={()=>handleDelete(p.id)}>
<Trash2 size={12}/>
</button>
)}
</div>
</div>
{/* Edit form only shown for THIS profile */}
{editingId === p.id && (
<ProfileForm
profile={p}
onSave={(form) => handleSave(form, p.id)}
onCancel={() => setEditingId(null)}
/>
)}
</div>
))}
{/* New profile */}
{editingId === 'new' ? (
<ProfileForm
title="Neues Profil"
onSave={(form) => handleSave(form, 'new')}
onCancel={() => setEditingId(null)}
/>
) : (
<button className="btn btn-secondary btn-full" style={{marginTop:12}}
onClick={() => setEditingId('new')}>
<Plus size={14}/> Neues Profil anlegen
</button>
)}
</div>
{/* Auth actions */}
<div className="card section-gap">
<div className="card-title">🔐 Konto</div>
<div style={{display:'flex',gap:8,flexDirection:'column'}}>
<button className="btn btn-secondary btn-full"
onClick={()=>setPinOpen(o=>!o)}
style={{display:'flex',alignItems:'center',gap:8,justifyContent:'center'}}>
<Key size={14}/> PIN / Passwort ändern
</button>
{pinOpen && (
<div style={{padding:12,background:'var(--surface2)',borderRadius:8}}>
<div style={{display:'flex',gap:8}}>
<input type="password" className="form-input" placeholder="Neue PIN/Passwort"
value={newPin} onChange={e=>setNewPin(e.target.value)} style={{flex:1}}/>
<button className="btn btn-primary" onClick={handlePinChange}>Setzen</button>
</div>
{pinMsg && <div style={{fontSize:12,color:pinMsg.startsWith('✓')?'var(--accent)':'#D85A30',marginTop:6}}>{pinMsg}</div>}
</div>
)}
<button className="btn btn-secondary btn-full"
onClick={handleLogout}
style={{display:'flex',alignItems:'center',gap:8,justifyContent:'center',color:'var(--warn)'}}>
<LogOut size={14}/> Ausloggen
</button>
</div>
</div>
{/* Admin Panel */}
{isAdmin && (
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}>
<div className="card-title" style={{margin:0,display:'flex',alignItems:'center',gap:6}}>
<Shield size={15} color="var(--accent)"/> Admin
</div>
<button className="btn btn-secondary" style={{fontSize:12}}
onClick={()=>setAdminOpen(o=>!o)}>
{adminOpen?'Schließen':'Öffnen'}
</button>
</div>
{adminOpen && <div style={{marginTop:12}}><AdminPanel/></div>}
</div>
)}
{/* Export */}
<div className="card section-gap">
<div className="card-title">Daten exportieren</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
Exportiert alle Daten von <strong>{activeProfile?.name}</strong>:
Gewicht, Umfänge, Caliper, Ernährung, Aktivität und KI-Auswertungen.
</p>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
{!canExport && (
<div style={{padding:'10px 12px',background:'#FCEBEB',borderRadius:8,
fontSize:13,color:'#D85A30',marginBottom:8}}>
🔒 Export ist für dein Profil nicht freigeschaltet. Bitte den Admin kontaktieren.
</div>
)}
{canExport && <>
<button className="btn btn-primary btn-full"
onClick={()=>window.open('/api/export/zip')}>
<Download size={14}/> ZIP exportieren
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}> je eine CSV pro Kategorie</span>
</button>
<button className="btn btn-secondary btn-full"
onClick={()=>window.open('/api/export/json')}>
<Download size={14}/> JSON exportieren
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}> maschinenlesbar, alles in einer Datei</span>
</button>
</>}
</div>
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
Der ZIP-Export enthält separate Dateien für Excel und eine lesbare KI-Auswertungsdatei.
</p>
</div>
{saved && (
<div style={{position:'fixed',bottom:80,left:'50%',transform:'translateX(-50%)',
background:'var(--accent)',color:'white',padding:'8px 20px',borderRadius:20,
fontSize:13,fontWeight:600,display:'flex',alignItems:'center',gap:6,zIndex:100}}>
<Check size={14}/> Gespeichert
</div>
)}
</div>
)
}

View File

@ -0,0 +1,150 @@
import { useState } from 'react'
import { Check } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
export default function SetupScreen() {
const { setup } = useAuth()
const [form, setForm] = useState({
name: '', pin: '', pin2: '',
avatar_color: COLORS[0],
sex: 'm', height: '',
auth_type: 'pin',
session_days: 30,
})
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
const set = (k,v) => setForm(f=>({...f,[k]:v}))
const handleSetup = async () => {
if (!form.name.trim()) return setError('Bitte Name eingeben')
if (form.pin.length < 4) return setError('PIN mind. 4 Zeichen')
if (form.pin !== form.pin2) return setError('PINs stimmen nicht überein')
setSaving(true); setError(null)
try {
await setup({
name: form.name.trim(),
pin: form.pin,
avatar_color: form.avatar_color,
sex: form.sex,
height: parseFloat(form.height)||178,
auth_type: form.auth_type,
session_days: form.session_days,
})
} catch(e) {
setError(e.message)
} finally { setSaving(false) }
}
const initials = form.name.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2)||'?'
return (
<div style={{minHeight:'100vh',display:'flex',flexDirection:'column',alignItems:'center',
justifyContent:'center',background:'var(--bg)',padding:24}}>
<div style={{width:'100%',maxWidth:360}}>
<div style={{textAlign:'center',marginBottom:28}}>
<div style={{fontSize:32,fontWeight:800,color:'var(--accent)'}}>BodyTrack</div>
<div style={{fontSize:15,color:'var(--text3)',marginTop:4}}>Erste Einrichtung</div>
<div style={{fontSize:12,color:'var(--text3)',marginTop:8,lineHeight:1.5}}>
Erstelle deinen Admin-Account. Du kannst danach weitere Profile anlegen.
</div>
</div>
{/* Avatar preview */}
<div style={{display:'flex',justifyContent:'center',marginBottom:20}}>
<div style={{width:64,height:64,borderRadius:'50%',background:form.avatar_color,
display:'flex',alignItems:'center',justifyContent:'center',
fontSize:24,fontWeight:700,color:'white'}}>
{initials}
</div>
</div>
<div className="card" style={{padding:20}}>
<div className="form-row">
<label className="form-label">Name</label>
<input type="text" className="form-input" placeholder="Dein Name"
value={form.name} onChange={e=>set('name',e.target.value)} autoFocus/>
<span className="form-unit"/>
</div>
<div style={{marginBottom:12}}>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:6}}>Avatar-Farbe</div>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{COLORS.map(c=>(
<div key={c} onClick={()=>set('avatar_color',c)}
style={{width:28,height:28,borderRadius:'50%',background:c,cursor:'pointer',
border:`3px solid ${form.avatar_color===c?'white':'transparent'}`,
boxShadow:form.avatar_color===c?`0 0 0 2px ${c}`:'none'}}/>
))}
</div>
</div>
<div className="form-row">
<label className="form-label">Geschlecht</label>
<select className="form-select" value={form.sex} onChange={e=>set('sex',e.target.value)}>
<option value="m">Männlich</option>
<option value="f">Weiblich</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Größe</label>
<input type="number" className="form-input" placeholder="178" min={100} max={250}
value={form.height} onChange={e=>set('height',e.target.value)}/>
<span className="form-unit">cm</span>
</div>
<div style={{borderTop:'1px solid var(--border)',margin:'14px 0'}}/>
<div className="form-row">
<label className="form-label">Login-Typ</label>
<select className="form-select" value={form.auth_type} onChange={e=>set('auth_type',e.target.value)}>
<option value="pin">4-stellige PIN</option>
<option value="password">Passwort</option>
</select>
</div>
<div className="form-row">
<label className="form-label">{form.auth_type==='pin'?'PIN':'Passwort'}</label>
<input type="password" className="form-input"
placeholder={form.auth_type==='pin'?'4-stellig':'Mind. 4 Zeichen'}
maxLength={form.auth_type==='pin'?4:undefined}
value={form.pin} onChange={e=>set('pin',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Bestätigen</label>
<input type="password" className="form-input" placeholder="Wiederholen"
maxLength={form.auth_type==='pin'?4:undefined}
value={form.pin2} onChange={e=>set('pin2',e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handleSetup()}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Eingeloggt</label>
<select className="form-select" value={form.session_days}
onChange={e=>set('session_days',parseInt(e.target.value))}>
<option value={7}>7 Tage</option>
<option value={30}>30 Tage</option>
<option value={0}>Immer</option>
</select>
</div>
{error && (
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,
fontSize:13,color:'#D85A30',marginBottom:10}}>{error}</div>
)}
<button className="btn btn-primary btn-full" onClick={handleSetup} disabled={saving}>
{saving
? <><div className="spinner" style={{width:14,height:14}}/> Einrichten</>
: <><Check size={15}/> Admin-Account erstellen</>}
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,170 @@
import { useState, useEffect, useRef } from 'react'
import { Pencil, Trash2, Check, X } from 'lucide-react'
import { api } from '../utils/api'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine } from 'recharts'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
function rollingAvg(arr, window=7) {
return arr.map((d,i)=>{
const s=arr.slice(Math.max(0,i-window+1),i+1).map(x=>x.weight).filter(v=>v!=null)
return s.length ? {...d, avg: Math.round(s.reduce((a,b)=>a+b,0)/s.length*10)/10} : d
})
}
export default function WeightScreen() {
const [entries, setEntries] = useState([])
const [editing, setEditing] = useState(null) // {id, date, weight, note}
const [newDate, setNewDate] = useState(dayjs().format('YYYY-MM-DD'))
const [newWeight,setNewWeight]= useState('')
const [newNote, setNewNote] = useState('')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const load = () => api.listWeight(365).then(data => setEntries(data))
useEffect(()=>{ load() },[])
const handleSave = async () => {
if (!newWeight) return
setSaving(true)
try {
await api.upsertWeight(newDate, parseFloat(newWeight), newNote)
setSaved(true); await load()
setTimeout(()=>setSaved(false), 2000)
setNewWeight(''); setNewNote('')
} finally { setSaving(false) }
}
const handleUpdate = async () => {
if (!editing) return
await api.updateWeight(editing.id, editing.date, parseFloat(editing.weight), editing.note||'')
setEditing(null); await load()
}
const handleDelete = async (id) => {
if (!confirm('Eintrag löschen?')) return
await api.deleteWeight(id); await load()
}
const sorted = [...entries].sort((a,b)=>a.date.localeCompare(b.date))
const withAvg = rollingAvg(sorted)
const chartData = withAvg.map(d=>({date:dayjs(d.date).format('DD.MM'), weight:d.weight, avg:d.avg}))
const weights = entries.map(e=>e.weight)
const avgAll = weights.length ? Math.round(weights.reduce((a,b)=>a+b,0)/weights.length*10)/10 : null
return (
<div>
<h1 className="page-title">Gewicht</h1>
{/* Eingabe */}
<div className="card section-gap">
<div className="card-title">Eintrag hinzufügen</div>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}}
value={newDate} onChange={e=>setNewDate(e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Gewicht</label>
<input type="number" className="form-input" min={20} max={300} step={0.1}
placeholder="kg" value={newWeight} onChange={e=>setNewWeight(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handleSave()}/>
<span className="form-unit">kg</span>
</div>
<div className="form-row">
<label className="form-label">Notiz</label>
<input type="text" className="form-input" placeholder="optional"
value={newNote} onChange={e=>setNewNote(e.target.value)}/>
<span className="form-unit"/>
</div>
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving||!newWeight}>
{saved ? <><Check size={15}/> Gespeichert!</>
: saving ? <><div className="spinner" style={{width:14,height:14}}/> </>
: 'Speichern'}
</button>
</div>
{/* Chart */}
{chartData.length >= 2 && (
<div className="card section-gap">
<div className="card-title">Verlauf ({entries.length} Einträge)</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
{avgAll && <ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1}/>}
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v} kg`, n==='weight'?'Gewicht':'Ø 7 Tage']}/>
<Line type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={1.5} dot={{r:2,fill:'#378ADD'}} name="weight"/>
<Line type="monotone" dataKey="avg" stroke="#1D9E75" strokeWidth={2.5} dot={false} name="avg"/>
</LineChart>
</ResponsiveContainer>
{weights.length >= 2 && (
<div style={{display:'flex',gap:8,marginTop:10}}>
{[['Min',Math.min(...weights),'var(--accent)'],['Max',Math.max(...weights),'var(--warn)'],['Ø',avgAll,'var(--text2)']].map(([l,v,c])=>(
<div key={l} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'6px 10px',textAlign:'center'}}>
<div style={{fontSize:15,fontWeight:700,color:c}}>{v} kg</div>
<div style={{fontSize:10,color:'var(--text3)'}}>{l}</div>
</div>
))}
</div>
)}
</div>
)}
{/* Liste */}
<div className="card section-gap">
<div className="card-title">Alle Einträge</div>
{entries.length===0 && <p className="muted">Noch keine Einträge.</p>}
{entries.map((e,i)=>{
const prev = entries[i+1]
const delta = prev ? Math.round((e.weight-prev.weight)*10)/10 : null
const isEditing = editing?.id===e.id
return (
<div key={e.id} style={{borderBottom:'1px solid var(--border)',padding:'8px 0'}}>
{isEditing ? (
<div style={{display:'flex',flexDirection:'column',gap:6}}>
<div style={{display:'flex',gap:8}}>
<input type="date" className="form-input" style={{flex:1}}
value={editing.date} onChange={ev=>setEditing(ed=>({...ed,date:ev.target.value}))}/>
<input type="number" className="form-input" style={{width:80}} step={0.1}
value={editing.weight} onChange={ev=>setEditing(ed=>({...ed,weight:ev.target.value}))}/>
<span style={{alignSelf:'center',fontSize:13,color:'var(--text3)'}}>kg</span>
</div>
<input type="text" className="form-input" placeholder="Notiz"
value={editing.note||''} onChange={ev=>setEditing(ed=>({...ed,note:ev.target.value}))}/>
<div style={{display:'flex',gap:6}}>
<button className="btn btn-primary" style={{flex:1}} onClick={handleUpdate}><Check size={14}/> Speichern</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={()=>setEditing(null)}><X size={14}/> Abbrechen</button>
</div>
</div>
) : (
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}>
<div style={{fontSize:15,fontWeight:600}}>{e.weight} kg
{delta!==null && <span style={{marginLeft:8,fontSize:12,fontWeight:600,color:delta<0?'var(--accent)':delta>0?'var(--warn)':'var(--text3)'}}>
{delta>0?'+':''}{delta} kg
</span>}
</div>
<div style={{fontSize:11,color:'var(--text3)'}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</div>
{e.note && <div style={{fontSize:11,color:'var(--text2)',fontStyle:'italic'}}>{e.note}</div>}
</div>
<button className="btn btn-secondary" style={{padding:'5px 8px'}} onClick={()=>setEditing({...e})}>
<Pencil size={13}/>
</button>
<button className="btn btn-danger" style={{padding:'5px 8px'}} onClick={()=>handleDelete(e.id)}>
<Trash2 size={13}/>
</button>
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,133 @@
// Lightweight Markdown renderer handles the subset used by the AI:
// ## Headings, **bold**, bullet lists, numbered lists, line breaks
export default function Markdown({ text }) {
if (!text) return null
const lines = text.split('\n')
const elements = []
let i = 0
const parseLine = (line) => {
// Parse inline **bold** and *italic*
const parts = []
let remaining = line
let key = 0
while (remaining.length > 0) {
const boldMatch = remaining.match(/^(.*?)\*\*(.*?)\*\*(.*)$/)
if (boldMatch) {
if (boldMatch[1]) parts.push(<span key={key++}>{boldMatch[1]}</span>)
parts.push(<strong key={key++}>{boldMatch[2]}</strong>)
remaining = boldMatch[3]
continue
}
const italicMatch = remaining.match(/^(.*?)\*(.*?)\*(.*)$/)
if (italicMatch) {
if (italicMatch[1]) parts.push(<span key={key++}>{italicMatch[1]}</span>)
parts.push(<em key={key++}>{italicMatch[2]}</em>)
remaining = italicMatch[3]
continue
}
parts.push(<span key={key++}>{remaining}</span>)
break
}
return parts.length === 1 && typeof parts[0].props?.children === 'string'
? parts[0].props.children
: parts
}
while (i < lines.length) {
const line = lines[i]
// Skip empty lines (add spacing)
if (line.trim() === '') {
elements.push(<div key={i} style={{ height: 8 }} />)
i++; continue
}
// H1
if (line.startsWith('# ')) {
elements.push(
<h1 key={i} style={{ fontSize: 18, fontWeight: 700, margin: '16px 0 8px', color: 'var(--text1)' }}>
{parseLine(line.slice(2))}
</h1>
)
i++; continue
}
// H2
if (line.startsWith('## ')) {
elements.push(
<h2 key={i} style={{ fontSize: 15, fontWeight: 700, margin: '14px 0 6px', color: 'var(--text1)',
display: 'flex', alignItems: 'center', gap: 6 }}>
{parseLine(line.slice(3))}
</h2>
)
i++; continue
}
// H3
if (line.startsWith('### ')) {
elements.push(
<h3 key={i} style={{ fontSize: 14, fontWeight: 600, margin: '10px 0 4px', color: 'var(--text1)' }}>
{parseLine(line.slice(4))}
</h3>
)
i++; continue
}
// Unordered list item
if (line.match(/^[-*] /)) {
const listItems = []
while (i < lines.length && lines[i].match(/^[-*] /)) {
listItems.push(
<li key={i} style={{ fontSize: 13, lineHeight: 1.65, color: 'var(--text2)', marginBottom: 4 }}>
{parseLine(lines[i].slice(2))}
</li>
)
i++
}
elements.push(
<ul key={`ul-${i}`} style={{ paddingLeft: 20, margin: '6px 0' }}>
{listItems}
</ul>
)
continue
}
// Numbered list item
if (line.match(/^\d+\. /)) {
const listItems = []
while (i < lines.length && lines[i].match(/^\d+\. /)) {
listItems.push(
<li key={i} style={{ fontSize: 13, lineHeight: 1.65, color: 'var(--text2)', marginBottom: 4 }}>
{parseLine(lines[i].replace(/^\d+\. /, ''))}
</li>
)
i++
}
elements.push(
<ol key={`ol-${i}`} style={{ paddingLeft: 20, margin: '6px 0' }}>
{listItems}
</ol>
)
continue
}
// Horizontal rule
if (line.match(/^---+$/)) {
elements.push(<hr key={i} style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '12px 0' }} />)
i++; continue
}
// Normal paragraph
elements.push(
<p key={i} style={{ fontSize: 13, lineHeight: 1.7, color: 'var(--text2)', margin: '4px 0' }}>
{parseLine(line)}
</p>
)
i++
}
return <div>{elements}</div>
}

98
frontend/src/utils/api.js Normal file
View File

@ -0,0 +1,98 @@
import { getToken } from '../context/AuthContext'
let _profileId = null
export function setProfileId(id) { _profileId = id }
const BASE = '/api'
function hdrs(extra={}) {
const h = {...extra}
if (_profileId) h['X-Profile-Id'] = _profileId
const token = getToken()
if (token) h['X-Auth-Token'] = token
return h
}
async function req(path, opts={}) {
const res = await fetch(BASE+path, {...opts, headers:hdrs(opts.headers||{})})
if (!res.ok) { const err=await res.text(); throw new Error(err) }
return res.json()
}
const json=(d)=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
const jput=(d)=>({method:'PUT', headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
export const api = {
// Profiles
listProfiles: () => req('/profiles'),
createProfile: (d) => req('/profiles', json(d)),
updateProfile: (id,d) => req(`/profiles/${id}`, jput(d)),
deleteProfile: (id) => req(`/profiles/${id}`, {method:'DELETE'}),
getProfile: () => req('/profile'),
updateActiveProfile:(d)=> req('/profile', jput(d)),
// Weight
listWeight: (l=365) => req(`/weight?limit=${l}`),
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),
updateWeight: (id,date,weight,note='') => req(`/weight/${id}`,jput({date,weight,note})),
deleteWeight: (id) => req(`/weight/${id}`,{method:'DELETE'}),
weightStats: () => req('/weight/stats'),
// Circumferences
listCirc: (l=100) => req(`/circumferences?limit=${l}`),
upsertCirc: (d) => req('/circumferences',json(d)),
updateCirc: (id,d) => req(`/circumferences/${id}`,jput(d)),
deleteCirc: (id) => req(`/circumferences/${id}`,{method:'DELETE'}),
// Caliper
listCaliper: (l=100) => req(`/caliper?limit=${l}`),
upsertCaliper: (d) => req('/caliper',json(d)),
updateCaliper: (id,d) => req(`/caliper/${id}`,jput(d)),
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
// Activity
listActivity: (l=200)=> req(`/activity?limit=${l}`),
createActivity: (d) => req('/activity',json(d)),
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
activityStats: () => req('/activity/stats'),
importActivityCsv: async(file)=>{
const fd=new FormData();fd.append('file',file)
const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})
const d=await r.json();if(!r.ok)throw new Error(d.detail||JSON.stringify(d));return d
},
// Photos
uploadPhoto: (file,date='')=>{
const fd=new FormData();fd.append('file',file);fd.append('date',date)
return fetch(`${BASE}/photos`,{method:'POST',body:fd,headers:hdrs()}).then(r=>r.json())
},
listPhotos: () => req('/photos'),
photoUrl: (pid) => `${BASE}/photos/${pid}`,
// Nutrition
importCsv: async(file)=>{
const fd=new FormData();fd.append('file',file)
const r=await fetch(`${BASE}/nutrition/import-csv`,{method:'POST',body:fd,headers:hdrs()})
const d=await r.json();if(!r.ok)throw new Error(d.detail||JSON.stringify(d));return d
},
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
nutritionCorrelations: () => req('/nutrition/correlations'),
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
// Stats & AI
getStats: () => req('/stats'),
insightTrend: () => req('/insights/trend',{method:'POST'}),
insightPipeline: () => req('/insights/pipeline',{method:'POST'}),
listInsights: () => req('/insights'),
latestInsights: () => req('/insights/latest'),
exportZip: () => window.open(`${BASE}/export/zip`),
exportJson: () => window.open(`${BASE}/export/json`),
exportCsv: () => window.open(`${BASE}/export/csv`),
// Admin
adminListProfiles: () => req('/admin/profiles'),
adminCreateProfile: (d) => req('/admin/profiles',json(d)),
adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}),
adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)),
changePin: (pin) => req('/auth/pin',json({pin})),
}

279
frontend/src/utils/calc.js Normal file
View File

@ -0,0 +1,279 @@
export function calcBodyFat(method, skinfolds, sex, age) {
const vals = Object.values(skinfolds).filter(v => v > 0)
if (!vals.length) return null
const siri = (D) => Math.max(3, Math.min(60, (495 / D) - 450))
if (method === 'jackson3') {
const { chest=0, abdomen=0, suprailiac=0, triceps=0, thigh=0 } = skinfolds
const s = sex === 'm' ? chest+abdomen+thigh : triceps+suprailiac+thigh
if (s === 0) return null
const D = sex === 'm'
? 1.10938 - 0.0008267*s + 0.0000016*s*s - 0.0002574*age
: 1.0994921 - 0.0009929*s + 0.0000023*s*s - 0.0001392*age
return siri(D)
}
if (method === 'jackson7') {
const { chest=0, axilla=0, triceps=0, subscap=0, suprailiac=0, abdomen=0, thigh=0 } = skinfolds
const s = chest+axilla+triceps+subscap+suprailiac+abdomen+thigh
if (s === 0) return null
const D = sex === 'm'
? 1.112 - 0.00043499*s + 0.00000055*s*s - 0.00028826*age
: 1.097 - 0.00046971*s + 0.00000056*s*s - 0.00012828*age
return siri(D)
}
if (method === 'durnin') {
const { biceps=0, triceps=0, subscap=0, suprailiac=0 } = skinfolds
const s = biceps+triceps+subscap+suprailiac
if (s === 0) return null
const logS = Math.log10(s)
const tbl = sex === 'm' ? [
[20,1.1620,0.0630],[30,1.1631,0.0632],[40,1.1422,0.0544],[50,1.1620,0.0700],[99,1.1715,0.0779]
] : [
[20,1.1549,0.0678],[30,1.1599,0.0717],[40,1.1423,0.0632],[50,1.1333,0.0612],[99,1.1339,0.0645]
]
const [, c, m] = tbl.find(([maxAge]) => age < maxAge) || tbl[tbl.length-1]
return siri(c - m * logS)
}
if (method === 'parrillo') {
const sum = Object.values(skinfolds).reduce((a,b) => a+(b||0), 0)
return Math.max(3, Math.min(50, (sum * 27) / 1000))
}
return null
}
export const METHOD_POINTS = {
jackson3: { m: ['chest','abdomen','thigh'], f: ['triceps','suprailiac','thigh'] },
jackson7: { m: ['chest','axilla','triceps','subscap','suprailiac','abdomen','thigh'],
f: ['chest','axilla','triceps','subscap','suprailiac','abdomen','thigh'] },
durnin: { m: ['biceps','triceps','subscap','suprailiac'], f: ['biceps','triceps','subscap','suprailiac'] },
parrillo: { m: ['chest','axilla','triceps','subscap','suprailiac','abdomen','thigh','calf_med','lowerback'],
f: ['chest','axilla','triceps','subscap','suprailiac','abdomen','thigh','calf_med','lowerback'] },
}
export const BF_CATEGORIES = {
m: [
{max:6, label:'Essenziell', color:'#378ADD', desc:'Unter diesem Wert sind lebenswichtige Fette betroffen.'},
{max:14, label:'Athletisch', color:'#1D9E75', desc:'Typisch für Leistungssportler sehr definiert.'},
{max:18, label:'Fit', color:'#639922', desc:'Gute Fitness, gesunder Bereich für aktive Menschen.'},
{max:25, label:'Durchschnitt', color:'#EF9F27', desc:'Normaler Bereich für die allgemeine Bevölkerung.'},
{max:100,label:'Übergewicht', color:'#D85A30', desc:'Erhöhtes Gesundheitsrisiko, Reduktion empfohlen.'},
],
f: [
{max:14, label:'Essenziell', color:'#378ADD', desc:'Unter diesem Wert sind lebenswichtige Fette betroffen.'},
{max:21, label:'Athletisch', color:'#1D9E75', desc:'Typisch für Leistungssportlerinnen sehr definiert.'},
{max:25, label:'Fit', color:'#639922', desc:'Gute Fitness, gesunder Bereich für aktive Frauen.'},
{max:32, label:'Durchschnitt', color:'#EF9F27', desc:'Normaler Bereich für die allgemeine Bevölkerung.'},
{max:100,label:'Übergewicht', color:'#D85A30', desc:'Erhöhtes Gesundheitsrisiko, Reduktion empfohlen.'},
],
}
export function getBfCategory(pct, sex) {
return BF_CATEGORIES[sex]?.find(c => pct <= c.max) || BF_CATEGORIES[sex]?.at(-1)
}
export function calcDerived(m, height) {
const out = {}
if (m.c_waist && m.c_hip) out.whr = Math.round(m.c_waist / m.c_hip * 100) / 100
if (m.c_waist && height) out.whtr = Math.round(m.c_waist / height * 100) / 100
if (m.lean_mass && height) out.ffmi = Math.round(m.lean_mass / ((height/100)**2) * 10) / 10
return out
}
export function getRuleBasedAssessment(current, previous, profile) {
const sex = profile?.sex || 'm'
const height = profile?.height || 178
const findings = []
if (current.body_fat_pct) {
const cat = getBfCategory(current.body_fat_pct, sex)
findings.push({
type: ['Athletisch','Fit'].includes(cat.label) ? 'good' : cat.label === 'Durchschnitt' ? 'info' : cat.label === 'Essenziell' ? 'warn' : 'bad',
icon: '🔥', text: `Körperfett: ${current.body_fat_pct}% ${cat.label}`, detail: cat.desc
})
if (previous?.body_fat_pct) {
const delta = Math.round((current.body_fat_pct - previous.body_fat_pct) * 10) / 10
if (Math.abs(delta) >= 0.5) findings.push({
type: delta < 0 ? 'good' : 'warn', icon: delta < 0 ? '📉' : '📈',
text: `Körperfett ${delta > 0?'+':''}${delta}% seit letzter Messung`,
detail: delta < 0 ? 'Positive Entwicklung weiter so!' : 'Leichter Anstieg Ernährung und Training überprüfen.'
})
}
}
if (current.lean_mass && previous?.lean_mass) {
const delta = Math.round((current.lean_mass - previous.lean_mass) * 10) / 10
if (Math.abs(delta) >= 0.3) findings.push({
type: delta > 0 ? 'good' : 'warn', icon: delta > 0 ? '💪' : '⚠️',
text: `Magermasse ${delta > 0?'+':''}${delta} kg`,
detail: delta > 0 ? 'Muskelaufbau detektiert Training zeigt Wirkung.' : 'Rückgang der Magermasse auf ausreichend Protein und Krafttraining achten.'
})
}
if (current.lean_mass && height) {
const ffmi = Math.round(current.lean_mass / ((height/100)**2) * 10) / 10
const limit = sex === 'm' ? 25 : 22
findings.push({
type: ffmi >= 20 && ffmi <= limit ? 'good' : ffmi < 17 ? 'info' : 'warn',
icon: '📐', text: `FFMI: ${ffmi}`,
detail: ffmi < 17 ? 'Unterdurchschnittliche Muskelmasse Krafttraining empfohlen.'
: ffmi < 20 ? 'Durchschnittliche Muskelmasse.'
: ffmi < 23 ? 'Gute Muskelmasse gut trainierter Körper.'
: ffmi <= limit ? 'Sehr hohe Muskelmasse Leistungssportler-Niveau.'
: `Sehr hoher FFMI bei Werten über ${limit} kritisch hinterfragen.`
})
}
if (current.c_waist && current.c_hip) {
const whr = Math.round(current.c_waist / current.c_hip * 100) / 100
const limit = sex === 'm' ? 0.90 : 0.85
findings.push({
type: whr < limit ? 'good' : whr < limit+0.05 ? 'warn' : 'bad',
icon: '⚖️', text: `Waist-Hip-Ratio: ${whr} (Ziel: <${limit})`,
detail: whr < limit ? 'Gesunde Fettverteilung kein erhöhtes kardiovaskuläres Risiko.'
: whr < limit+0.05 ? 'Grenzwertiger Bereich Taillenumfang reduzieren empfohlen.'
: 'Erhöhtes kardiovaskuläres Risiko durch abdominelle Fettverteilung.'
})
}
if (current.c_waist && height) {
const whtr = Math.round(current.c_waist / height * 100) / 100
findings.push({
type: whtr < 0.50 ? 'good' : whtr < 0.60 ? 'warn' : 'bad',
icon: '📏', text: `Waist-to-Height-Ratio: ${whtr} (Ziel: <0,50)`,
detail: whtr < 0.50 ? 'Optimales Verhältnis Taille halb so groß wie Körpergröße.'
: whtr < 0.60 ? 'Leicht erhöhtes Risiko Taillenumfang sollte reduziert werden.'
: 'Deutlich erhöhtes Gesundheitsrisiko ärztliche Beratung empfohlen.'
})
}
if (current.c_waist) {
const limit = sex === 'm' ? 94 : 80
const limitHigh = sex === 'm' ? 102 : 88
if (current.c_waist > limit) findings.push({
type: current.c_waist > limitHigh ? 'bad' : 'warn',
icon: '🔴', text: `Taillenumfang ${current.c_waist} cm (WHO-Grenzwert: ${limit} cm)`,
detail: current.c_waist > limitHigh
? `Stark erhöhtes Risiko (WHO: über ${limitHigh} cm = hohes Risiko).`
: `Leicht erhöhtes metabolisches Risiko laut WHO-Kriterien.`
})
if (previous?.c_waist) {
const delta = Math.round((current.c_waist - previous.c_waist) * 10) / 10
if (Math.abs(delta) >= 1) findings.push({
type: delta < 0 ? 'good' : 'warn', icon: delta < 0 ? '✅' : '📊',
text: `Taille ${delta > 0?'+':''}${delta} cm seit letzter Messung`,
detail: delta < 0 ? 'Taillenumfang nimmt ab gute Entwicklung!' : 'Taille ist gewachsen Ernährung überprüfen.'
})
}
}
if (current.weight && previous?.weight) {
const delta = Math.round((current.weight - previous.weight) * 10) / 10
if (Math.abs(delta) >= 0.3) findings.push({
type: 'info', icon: '⚖️',
text: `Gewicht ${delta > 0?'+':''}${delta} kg seit letzter Messung`,
detail: 'Kombination mit Körperfett-Verlauf beachten Gewicht allein ist wenig aussagekräftig.'
})
}
const goods = findings.filter(f => f.type === 'good').length
const bads = findings.filter(f => f.type === 'bad').length
const warns = findings.filter(f => f.type === 'warn').length
let summary, summaryType
if (findings.length === 0) {
summary = 'Zu wenig Daten. Bitte Umfänge und Körperfett ergänzen.'; summaryType = 'info'
} else if (bads >= 2) {
summary = 'Mehrere Werte im kritischen Bereich gezielte Maßnahmen empfohlen.'; summaryType = 'bad'
} else if (bads === 1 || warns >= 2) {
summary = 'Einige Werte außerhalb des optimalen Bereichs Verbesserungspotenzial vorhanden.'; summaryType = 'warn'
} else if (goods >= 2) {
summary = 'Gute Körperzusammensetzung weiter so!'; summaryType = 'good'
} else {
summary = 'Werte im normalen Bereich regelmäßig weiter messen.'; summaryType = 'info'
}
return { findings, summary, summaryType }
}
export const CIRCUMFERENCE_GUIDE = [
{ id:'c_neck', name:'Hals', color:'#1D9E75',
where:'Direkt unterhalb des Adamsapfels, schmalste Stelle des Halses',
posture:'Gerade stehen, Kopf neutral, nicht nach vorne beugen',
how:'Waagerecht, 1 Finger Luft zwischen Band und Hals',
tip:'Morgens nüchtern messen für konsistente Werte' },
{ id:'c_chest', name:'Brust', color:'#378ADD',
where:'Breiteste Stelle des Brustkorbs, über den Brustmuskeln (Männer) bzw. vollsten Stelle der Brust (Frauen)',
posture:'Aufrecht, Arme locker seitlich am Ende normaler Ausatmung messen',
how:'Waagerecht, parallel zum Boden, fest aber nicht einschneidend',
tip:'Nicht einatmen beim Messen Werte ändern sich um bis zu 5 cm!' },
{ id:'c_waist', name:'Taille', color:'#EF9F27',
where:'Schmalste Stelle des Rumpfes meist 23 cm oberhalb des Bauchnabels',
posture:'Aufrecht, Bauch entspannen, Arme locker hängen lassen',
how:'Waagerecht, eng aber nicht zusammenpressend',
tip:'Beim seitlichen Beugen wird die schmalste Stelle gut sichtbar' },
{ id:'c_belly', name:'Bauch', color:'#D85A30',
where:'Exakt auf Höhe des Bauchnabels',
posture:'Stehend, Bauch vollständig entspannen nicht einziehen!',
how:'Waagerecht, ohne Druck',
tip:'Wichtigster Einzelwert für viszerales Fett und Gesundheitsrisiko' },
{ id:'c_hip', name:'Hüfte', color:'#D4537E',
where:'Breiteste Stelle des Gesäßes, ca. 1520 cm unterhalb des Bauchnabels',
posture:'Aufrecht, Füße zusammen, Gewicht gleichmäßig',
how:'Über die breiteste Stelle des Gesäßes, waagerecht',
tip:'Für WHR: Taille ÷ Hüfte (Ziel: <0,85 Frauen / <0,90 Männer)' },
{ id:'c_thigh', name:'Oberschenkel', color:'#7F77DD',
where:'Dickste Stelle des Oberschenkels, 5 cm unterhalb des Schritts',
posture:'Aufrecht, Gewicht gleichmäßig nicht auf ein Bein verlagern!',
how:'Waagerecht, immer rechte Seite, gleicher Abstand vom Schritt',
tip:'Mit Lineal vorher markieren für reproduzierbare Werte' },
{ id:'c_calf', name:'Wade', color:'#639922',
where:'Dickste Stelle der Wade, Mitte zwischen Knöchel und Kniebeuge',
posture:'Aufrecht, Gewicht gleichmäßig verteilt',
how:'Waagerecht, Muskel entspannt',
tip:'Morgens messen abends schwellen Beine durch Wassereinlagerungen an' },
{ id:'c_arm', name:'Oberarm', color:'#1D9E75',
where:'Dickste Stelle, Mitte zwischen Schultergelenk und Ellenbogen',
posture:'Arm locker hängen lassen und entspannen',
how:'Waagerecht, senkrecht zur Längsachse',
tip:'Immer denselben Arm (rechts); auch angespannt messen und notieren' },
]
export const CALIPER_GUIDE = {
chest: { name:'Brust', color:'#378ADD',
where:'Diagonale Falte, halb zwischen Achselhöhle und Brustwarze (Männer); 1/3 des Abstands (Frauen)',
posture:'Aufrecht, Arm leicht angehoben', how:'Diagonale Falte (45°)',
tip:'Liegt medial der Achselfalte nicht zu weit nach außen greifen' },
axilla: { name:'Achsel', color:'#D4537E',
where:'Mittlere Achsellinie, auf Höhe des Xiphoids (Brustbeinansatz)',
posture:'Arm leicht nach vorne', how:'Vertikale Falte',
tip:'Schwieriger Punkt Helfer sinnvoll' },
triceps: { name:'Trizeps', color:'#EF9F27',
where:'Rückseite Oberarm, Mitte zwischen Schultergelenk und Ellenbogen',
posture:'Arm hängt entspannt seitlich', how:'Vertikale Falte, parallel zur Längsachse',
tip:'Wichtigster Punkt in Frauen-Formeln Arm vollständig entspannen' },
subscap: { name:'Schulterblatt', color:'#7F77DD',
where:'12 cm unterhalb der unteren Schulterblatt-Ecke',
posture:'Arm hängt locker, leicht nach hinten', how:'Diagonale Falte (45°) entlang natürlicher Hautlinien',
tip:'Arm nach hinten halten lassen für besseren Zugang' },
suprailiac: { name:'Hüftkamm', color:'#D85A30',
where:'Direkt oberhalb des Hüftkamms (Crista iliaca), vordere Achsellinie',
posture:'Aufrecht, Arme leicht angehoben', how:'Diagonale Falte (45° nach innen-unten)',
tip:'Liegt ÜBER dem Hüftknochen nicht mit Bauch-Punkt verwechseln' },
abdomen: { name:'Bauch', color:'#D85A30',
where:'2 cm rechts neben dem Bauchnabel',
posture:'Stehend, Bauch entspannen', how:'Horizontale Falte',
tip:'Bauch vollständig entspannen nicht einziehen!' },
thigh: { name:'Oberschenkel', color:'#1D9E75',
where:'Vorderseite, Mitte zwischen Leiste und Kniescheibe',
posture:'Gewicht auf linkes Bein verlagern (rechter Muskel entspannt)', how:'Vertikale Falte',
tip:'Gewicht aufs andere Bein Muskel muss entspannt sein' },
calf_med: { name:'Wade (medial)', color:'#639922',
where:'Innenseite der Wade, dickste Stelle',
posture:'Sitzend, Fuß flach, Knie 90°', how:'Vertikale Falte',
tip:'Bein vollständig entspannen' },
biceps: { name:'Bizeps', color:'#1D9E75',
where:'Vorderseite Oberarm, Mitte zwischen Schultergelenk und Ellenbogen',
posture:'Arm hängt entspannt', how:'Vertikale Falte',
tip:'Nur Durnin-Methode' },
lowerback: { name:'Lendenwirbel', color:'#888780',
where:'Über Lendenwirbelsäule, 2 cm seitlich der Mittellinie auf Höhe L4',
posture:'Leicht nach vorne gebeugt', how:'Horizontale Falte',
tip:'Helfer bitten schwer allein erreichbar' },
}

View File

@ -0,0 +1,120 @@
// Umfang-Messpunkte
export const CIRCUMFERENCE_POINTS = [
{
id: 'c_neck', label: 'Hals', color: '#1D9E75',
where: 'Direkt unterhalb des Adamsapfels, an der schmalsten Stelle des Halses',
posture: 'Gerade stehen, Kopf in neutraler Position, nicht nach vorne beugen',
how: 'Waagerecht anlegen, 1 Finger Luft zwischen Maßband und Hals',
tip: 'Wichtig für Hemd- und Kragengrößen morgens nüchtern messen'
},
{
id: 'c_chest', label: 'Brust', color: '#378ADD',
where: 'An der breitesten Stelle des Brustkorbs, über den Brustmuskeln / Brustwarzen (Männer) bzw. der vollsten Stelle der Brust (Frauen)',
posture: 'Aufrecht stehen, Arme locker an den Seiten messen am Ende einer normalen Ausatmung',
how: 'Waagerecht, parallel zum Boden, fest aber nicht einschneidend',
tip: 'Nicht einatmen beim Messen! Werte verändern sich um bis zu 5 cm durch einen Atemzug'
},
{
id: 'c_waist', label: 'Taille', color: '#EF9F27',
where: 'An der natürlich schmalsten Stelle des Rumpfes meist 23 cm oberhalb des Bauchnabels',
posture: 'Aufrecht stehen, Bauch entspannen, Arme locker hängen lassen',
how: 'Waagerecht, eng aber nicht zusammenpressend',
tip: 'Morgens nüchtern messen nicht nach dem Essen!'
},
{
id: 'c_belly', label: 'Bauch', color: '#D85A30',
where: 'Exakt auf Höhe des Bauchnabels auch wenn dies nicht die schmalste Stelle ist',
posture: 'Aufrecht stehen, Bauch vollständig entspannen keinesfalls einziehen!',
how: 'Waagerecht, ohne Druck weder spannen noch locker lassen',
tip: 'Wichtigster Indikator für viszerales Fett und Gesundheitsrisiko'
},
{
id: 'c_hip', label: 'Hüfte', color: '#D4537E',
where: 'An der breitesten Stelle des Gesäßes, ca. 1520 cm unterhalb des Bauchnabels',
posture: 'Aufrecht stehen, Füße zusammen, Gewicht gleichmäßig verteilt',
how: 'Über die breiteste Stelle des Gesäßes führen, waagerecht halten',
tip: 'Waist-Hip-Ratio: Taille ÷ Hüfte (Ziel: <0,85 Frauen / <0,90 Männer)'
},
{
id: 'c_thigh', label: 'Oberschenkel', color: '#7F77DD',
where: 'An der dicksten Stelle des Oberschenkels, direkt unterhalb der Gesäßfalte ca. 5 cm unterhalb des Schritts',
posture: 'Aufrecht stehen, Gewicht gleichmäßig auf beide Beine verteilt',
how: 'Waagerecht anlegen, immer denselben Abstand vom Schritt nehmen',
tip: 'Gewicht auf das andere Bein verlagern damit der Muskel entspannt ist'
},
{
id: 'c_calf', label: 'Wade', color: '#639922',
where: 'An der dicksten Stelle der Wade, ca. in der Mitte zwischen Knöchel und Kniebeuge',
posture: 'Aufrecht stehen, Gewicht gleichmäßig verteilt, nicht auf Zehenspitzen',
how: 'Waagerecht anlegen, Muskel entspannt',
tip: 'Morgens messen gegen Abend schwellen Beine durch Wassereinlagerungen an'
},
{
id: 'c_arm', label: 'Oberarm', color: '#1D9E75',
where: 'An der dicksten Stelle des Oberarms Mitte zwischen Schultergelenk und Ellenbogen',
posture: 'Arm locker hängen lassen und entspannen',
how: 'Waagerecht anlegen, senkrecht zur Längsachse des Arms',
tip: 'Immer denselben Arm messen (meist rechts) beide Werte notieren (entspannt & angespannt)'
},
]
// Caliper-Methoden und Messpunkte
export const CALIPER_POINTS = {
chest: { label: 'Brust', color: '#378ADD',
where: 'Diagonale Falte, halb zwischen Achselhöhle und Brustwarze (Männer); 1/3 des Abstands (Frauen)',
posture: 'Aufrecht stehen, Arm leicht angehoben',
how: 'Diagonale Falte (45°), parallel zur Hautlinie greifen',
tip: 'Liegt medial der Achselfalte häufig in Männer-Formeln' },
axilla: { label: 'Achsel', color: '#D4537E',
where: 'Mittlere Achsellinie, auf Höhe des Xiphoids (Brustbein-Ansatz)',
posture: 'Arm leicht nach vorne, Rumpf gerade',
how: 'Vertikale Falte',
tip: 'Schwieriger Punkt Helfer sinnvoll' },
triceps: { label: 'Trizeps', color: '#EF9F27',
where: 'Rückseite des Oberarms, Mitte zwischen Schultergelenk und Ellenbogen',
posture: 'Arm hängt entspannt seitlich am Körper',
how: 'Vertikale Falte, parallel zur Längsachse des Arms',
tip: 'Arm muss vollständig entspannt sein wichtigster Punkt in Frauen-Formeln' },
subscap: { label: 'Schulterblatt', color: '#7F77DD',
where: '12 cm unterhalb der unteren Schulterblatt-Ecke, schräg nach außen',
posture: 'Arm hängt locker, leicht nach hinten',
how: 'Diagonale Falte (45°) in Richtung der natürlichen Hautlinien',
tip: 'Arm nach hinten halten lassen für besseren Zugang' },
suprailiac: { label: 'Hüftkamm', color: '#D85A30',
where: 'Direkt oberhalb des Hüftkamms (Crista iliaca), vordere Achsellinie',
posture: 'Aufrecht, Arme leicht angehoben',
how: 'Diagonale Falte (45° nach innen-unten), entlang der Hüftkammlinie',
tip: 'Nicht mit dem Bauch-Punkt verwechseln liegt ÜBER dem Hüftknochen' },
abdomen: { label: 'Bauch', color: '#D85A30',
where: '2 cm rechts neben dem Bauchnabel',
posture: 'Stehend, Bauch entspannen',
how: 'Horizontale Falte, Hautspannung vermeiden',
tip: 'Bauch vollständig entspannen nicht einziehen!' },
thigh: { label: 'Oberschenkel', color: '#1D9E75',
where: 'Vorderseite des Oberschenkels, Mitte zwischen Leiste und Kniescheibe',
posture: 'Sitzend oder stehend; Gewicht auf linkes Bein (rechter Muskel entspannt)',
how: 'Vertikale Falte, parallel zur Längsachse des Beins',
tip: 'Gewicht auf das andere Bein verlagern damit der Muskel entspannt ist' },
calf_med: { label: 'Wade (medial)', color: '#639922',
where: 'Innenseite der Wade, an der dicksten Stelle (maximaler Umfang)',
posture: 'Sitzend, Fuß flach auf dem Boden, Knie 90°',
how: 'Vertikale Falte',
tip: 'Bein muss komplett entspannt sein' },
biceps: { label: 'Bizeps', color: '#1D9E75',
where: 'Vorderseite des Oberarms, Mitte zwischen Schultergelenk und Ellenbogen',
posture: 'Arm hängt entspannt',
how: 'Vertikale Falte',
tip: 'Nur in Durnin-Methode; Arm vollständig entspannen' },
lowerback: { label: 'Lendenwirbel', color: '#888780',
where: 'Über der Lendenwirbelsäule, 2 cm seitlich der Mittellinie auf Höhe L4',
posture: 'Leicht nach vorne gebeugt, Muskeln entspannen',
how: 'Horizontale Falte',
tip: 'Schwieriger Punkt einen Helfer bitten' },
}
export const CALIPER_METHODS = {
jackson3: { label: 'Jackson/Pollock 3', points_m: ['chest','abdomen','thigh'], points_f: ['triceps','suprailiac','thigh'] },
jackson7: { label: 'Jackson/Pollock 7', points_m: ['chest','axilla','triceps','subscap','suprailiac','abdomen','thigh'], points_f: ['chest','axilla','triceps','subscap','suprailiac','abdomen','thigh'] },
durnin: { label: 'Durnin/Womersley 4', points_m: ['biceps','triceps','subscap','suprailiac'], points_f: ['biceps','triceps','subscap','suprailiac'] },
parrillo: { label: 'Parrillo 9', points_m: ['chest','axilla','triceps','subscap','suprailiac','abdomen','thigh','calf_med','lowerback'], points_f: ['chest','axilla','triceps','subscap','suprailiac','abdomen','thigh','calf_med','lowerback'] },
}

View File

@ -0,0 +1,184 @@
import { getBfCategory, calcDerived } from './calc.js'
export function getInterpretation(measurement, profile, prevMeasurement = null) {
const results = []
const sex = profile?.sex || 'm'
const height = profile?.height || 178
const age = profile?.dob
? Math.floor((Date.now() - new Date(profile.dob)) / (365.25 * 24 * 3600 * 1000))
: 30
const m = measurement
// lean_mass and body_fat_pct come from caliper_log
const derived = calcDerived(m, height)
// ── Körperfett ─────────────────────────────────────────────────────────────
if (m.body_fat_pct) {
const cat = getBfCategory(m.body_fat_pct, sex)
const ranges = sex === 'm'
? { essential: 6, athletic: 14, fit: 18, avg: 25 }
: { essential: 14, athletic: 21, fit: 25, avg: 32 }
let msg = '', detail = ''
if (m.body_fat_pct <= ranges.essential) {
msg = 'Sehr niedriger Körperfettanteil'; detail = 'Essenzielle Fettwerte nur für Leistungssportler geeignet, auf Dauer nicht empfehlenswert.'
} else if (m.body_fat_pct <= ranges.athletic) {
msg = 'Athletischer Körperfettanteil'; detail = 'Ausgezeichnet. Typisch für aktive Sportler mit hohem Trainingsvolumen.'
} else if (m.body_fat_pct <= ranges.fit) {
msg = 'Guter Körperfettanteil'; detail = 'Sehr gute Fitness-Kategorie. Gesund und gut in Form.'
} else if (m.body_fat_pct <= ranges.avg) {
msg = 'Durchschnittlicher Körperfettanteil'; detail = 'Im normalen Bereich. Verbesserung durch Kombination aus Kraft- und Ausdauertraining möglich.'
} else {
msg = 'Erhöhter Körperfettanteil'; detail = 'Über dem empfohlenen Bereich. Ernährungsumstellung und regelmäßiges Training empfohlen.'
}
results.push({
category: 'Körperfett',
icon: '🫧',
status: m.body_fat_pct <= ranges.fit ? 'good' : m.body_fat_pct <= ranges.avg ? 'warn' : 'bad',
title: msg,
detail,
value: `${m.body_fat_pct}%`,
badge: cat?.label,
color: cat?.color,
})
}
// ── Waist-Hip-Ratio ────────────────────────────────────────────────────────
if (derived.whr) {
const limit = sex === 'm' ? 0.90 : 0.85
const limitHigh = sex === 'm' ? 1.0 : 0.95
let status, title, detail
if (derived.whr < limit) {
status = 'good'; title = 'Günstige Fettverteilung'
detail = `Dein WHR von ${derived.whr} liegt unter dem Grenzwert (${limit}). Birnenförmige Fettverteilung metabolisch günstig.`
} else if (derived.whr < limitHigh) {
status = 'warn'; title = 'Grenzwertiger WHR'
detail = `Dein WHR von ${derived.whr} liegt leicht über dem Zielwert (${limit}). Apfelförmige Tendenz Bauchfett reduzieren empfohlen.`
} else {
status = 'bad'; title = 'Erhöhtes Risiko durch Fettverteilung'
detail = `WHR von ${derived.whr} deutlich über dem Grenzwert. Erhöhtes kardiovaskuläres Risiko durch viszerales Fett.`
}
results.push({ category: 'Fettverteilung', icon: '📐', status, title, detail, value: derived.whr.toString() })
}
// ── Waist-to-Height ────────────────────────────────────────────────────────
if (derived.whtr) {
let status, title, detail
if (derived.whtr < 0.40) {
status = 'warn'; title = 'Sehr schlanke Taille'
detail = `WHtR ${derived.whtr} möglicherweise zu wenig Körpermasse.`
} else if (derived.whtr < 0.50) {
status = 'good'; title = 'Optimale Taillen-Größen-Relation'
detail = `WHtR ${derived.whtr} im optimalen Bereich. Geringstes kardiovaskuläres Risiko.`
} else if (derived.whtr < 0.60) {
status = 'warn'; title = 'Leicht erhöhter WHtR'
detail = `WHtR ${derived.whtr} Ziel ist unter 0,50. Moderat erhöhtes Risiko.`
} else {
status = 'bad'; title = 'Stark erhöhter WHtR'
detail = `WHtR ${derived.whtr} deutlich erhöhtes Risiko. Taille sollte weniger als die Hälfte der Körpergröße betragen.`
}
results.push({ category: 'Taille/Größe', icon: '📏', status, title, detail, value: derived.whtr.toString() })
}
// ── FFMI ───────────────────────────────────────────────────────────────────
if (derived.ffmi) {
const naturalLimit = sex === 'm' ? 25 : 22
let status, title, detail
if (derived.ffmi < (sex === 'm' ? 18 : 15)) {
status = 'warn'; title = 'Unterdurchschnittliche Muskelmasse'
detail = `FFMI ${derived.ffmi} Krafttraining kann die Muskelmasse und den Grundumsatz deutlich verbessern.`
} else if (derived.ffmi < (sex === 'm' ? 22 : 19)) {
status = 'good'; title = 'Durchschnittliche Muskelmasse'
detail = `FFMI ${derived.ffmi} gute Basis. Mit regelmäßigem Krafttraining weiter ausbaubar.`
} else if (derived.ffmi <= naturalLimit) {
status = 'good'; title = 'Überdurchschnittliche Muskelmasse'
detail = `FFMI ${derived.ffmi} sehr gut. Oberes natürliches Spektrum für Kraftsportler.`
} else {
status = 'warn'; title = 'Außergewöhnlich hohe Muskelmasse'
detail = `FFMI ${derived.ffmi} oberhalb der natürlichen Grenze (~${naturalLimit}). Selten ohne unterstützende Mittel erreichbar.`
}
results.push({ category: 'Muskelmasse', icon: '💪', status, title, detail, value: derived.ffmi.toString() })
}
// ── BMI (aus Gewicht + Größe) ──────────────────────────────────────────────
if (m.weight && height) {
const bmi = Math.round(m.weight / ((height / 100) ** 2) * 10) / 10
let status, title, detail
if (bmi < 18.5) {
status = 'warn'; title = 'Untergewicht (BMI)'
detail = `BMI ${bmi} unter 18,5. Auf ausreichende Kalorienzufuhr und Nährstoffversorgung achten.`
} else if (bmi < 25) {
status = 'good'; title = 'Normalgewicht (BMI)'
detail = `BMI ${bmi} im optimalen Bereich (18,524,9).`
} else if (bmi < 30) {
status = 'warn'; title = 'Übergewicht (BMI)'
detail = `BMI ${bmi} leichtes Übergewicht. BMI allein ist wenig aussagekräftig bei Muskelmasse Körperfett-% beachten.`
} else {
status = 'bad'; title = 'Adipositas (BMI)'
detail = `BMI ${bmi} deutliches Übergewicht. Ärztliche Beratung empfohlen.`
}
results.push({ category: 'BMI', icon: '⚖️', status, title, detail, value: bmi.toString() })
}
// ── Vergleich zur letzten Messung ──────────────────────────────────────────
if (prevMeasurement) {
const changes = []
const p = prevMeasurement
const days = Math.round((new Date(m.date) - new Date(p.date)) / (1000 * 60 * 60 * 24))
if (m.body_fat_pct && p.body_fat_pct) {
const diff = Math.round((m.body_fat_pct - p.body_fat_pct) * 10) / 10
if (Math.abs(diff) >= 0.3) changes.push({ label: 'Körperfett', diff, unit: '%', invert: true })
}
if (m.weight && p.weight) {
const diff = Math.round((m.weight - p.weight) * 10) / 10
if (Math.abs(diff) >= 0.2) changes.push({ label: 'Gewicht', diff, unit: 'kg', invert: true })
}
if (m.lean_mass && p.lean_mass) {
const diff = Math.round((m.lean_mass - p.lean_mass) * 10) / 10
if (Math.abs(diff) >= 0.2) changes.push({ label: 'Magermasse', diff, unit: 'kg', invert: false })
}
if (m.c_waist && p.c_waist) {
const diff = Math.round((m.c_waist - p.c_waist) * 10) / 10
if (Math.abs(diff) >= 0.5) changes.push({ label: 'Taille', diff, unit: 'cm', invert: true })
}
if (m.c_belly && p.c_belly) {
const diff = Math.round((m.c_belly - p.c_belly) * 10) / 10
if (Math.abs(diff) >= 0.5) changes.push({ label: 'Bauch', diff, unit: 'cm', invert: true })
}
if (changes.length > 0) {
const positiveChanges = changes.filter(c => c.invert ? c.diff < 0 : c.diff > 0)
const negativeChanges = changes.filter(c => c.invert ? c.diff > 0 : c.diff < 0)
const detail = changes.map(c => {
const sign = c.diff > 0 ? '+' : ''
const good = c.invert ? c.diff < 0 : c.diff > 0
return `${c.label}: ${sign}${c.diff} ${c.unit} ${good ? '✓' : '↑'}`
}).join(' · ')
results.push({
category: `Seit letzter Messung (${days} Tage)`,
icon: '📊',
status: positiveChanges.length >= negativeChanges.length ? 'good' : 'warn',
title: positiveChanges.length > negativeChanges.length
? 'Positive Entwicklung seit letzter Messung'
: negativeChanges.length > positiveChanges.length
? 'Verschlechterung seit letzter Messung'
: 'Gemischte Entwicklung seit letzter Messung',
detail,
value: `${days}d`,
})
}
}
return results
}
export function getStatusColor(status) {
return status === 'good' ? '#1D9E75' : status === 'warn' ? '#EF9F27' : '#D85A30'
}
export function getStatusBg(status) {
return status === 'good' ? '#E1F5EE' : status === 'warn' ? '#FAEEDA' : '#FCEBEB'
}

37
frontend/vite.config.js Normal file
View File

@ -0,0 +1,37 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icon-192.png', 'icon-512.png'],
manifest: {
name: 'Mitai Jinkendo',
short_name: 'Mitai',
description: 'Körpervermessung & Körperfett Tracker',
theme_color: '#1D9E75',
background_color: '#f4f3ef',
display: 'standalone',
orientation: 'portrait',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [{
urlPattern: /^\/api\//,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', expiration: { maxEntries: 50 } }
}]
}
})
],
server: {
proxy: { '/api': 'http://localhost:8000' }
}
})

34
nginx/certbot-setup.sh Normal file
View File

@ -0,0 +1,34 @@
#!/bin/bash
# Let's Encrypt Setup für mitai.jinkendo.de
# Voraussetzungen:
# - nginx läuft
# - Port 80 ist von außen erreichbar (DynDNS/MyFRITZ!)
# - mitai.jinkendo.de zeigt auf diese IP
DOMAIN="mitai.jinkendo.de"
EMAIL="lars@stommer.de" # Für Let's Encrypt Benachrichtigungen
echo "=== Let's Encrypt Setup für $DOMAIN ==="
# Certbot installieren
sudo apt-get update
sudo apt-get install -y certbot python3-certbot-nginx
# Zertifikat holen
sudo certbot --nginx \
-d $DOMAIN \
--email $EMAIL \
--agree-tos \
--non-interactive \
--redirect
echo ""
echo "=== Auto-Renewal einrichten ==="
# Certbot richtet automatisch einen systemd-Timer ein
# Prüfen mit:
sudo systemctl status certbot.timer
echo ""
echo "=== Fertig! ==="
echo "Zertifikat läuft 90 Tage, erneuert sich automatisch alle 60 Tage."
echo "Prüfen mit: sudo certbot renew --dry-run"

67
nginx/nginx.conf Normal file
View File

@ -0,0 +1,67 @@
# Mitai Jinkendo - nginx Production Config
# Place at: /etc/nginx/sites-available/jinkendo
server {
listen 80;
server_name mitai.jinkendo.de;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all HTTP to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name mitai.jinkendo.de;
# SSL - managed by Certbot
ssl_certificate /etc/letsencrypt/live/mitai.jinkendo.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mitai.jinkendo.de/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security Headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
# Rate limiting zones (defined in http block)
# limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
# limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
# API - Backend
location /api/ {
# limit_req zone=api burst=20 nodelay;
proxy_pass http://127.0.0.1:8002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # KI-Calls können länger dauern
client_max_body_size 20M; # CSV + Foto Uploads
}
# Frontend - React PWA
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_cache_bypass $http_upgrade;
}
}