feat: initial commit – Mitai Jinkendo v9a
This commit is contained in:
commit
89b6c0b072
29
.env.example
Normal file
29
.env.example
Normal 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
|
||||
38
.gitea/workflows/deploy.yml
Normal file
38
.gitea/workflows/deploy.yml
Normal 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
33
.gitea/workflows/test.yml
Normal 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
60
.gitignore
vendored
Normal 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
154
CLAUDE.md
Normal 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
81
README.md
Normal 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
216
SETUP.md
Normal 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
7
backend/Dockerfile
Normal 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
2077
backend/main.py
Normal file
File diff suppressed because it is too large
Load Diff
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal 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
29
docker-compose.dev.yml
Normal 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
39
docker-compose.yml
Normal 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
11
frontend/Dockerfile
Normal 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
19
frontend/index.html
Normal 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
16
frontend/nginx.conf
Normal 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
26
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon-32.png
Normal file
BIN
frontend/public/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 B |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 B |
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
135
frontend/src/App.jsx
Normal file
135
frontend/src/App.jsx
Normal 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
156
frontend/src/app.css
Normal 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; }
|
||||
121
frontend/src/context/AuthContext.jsx
Normal file
121
frontend/src/context/AuthContext.jsx
Normal 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)
|
||||
}
|
||||
63
frontend/src/context/ProfileContext.jsx
Normal file
63
frontend/src/context/ProfileContext.jsx
Normal 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
9
frontend/src/main.jsx
Normal 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>
|
||||
)
|
||||
315
frontend/src/pages/ActivityPage.jsx
Normal file
315
frontend/src/pages/ActivityPage.jsx
Normal 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="1–10" 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>
|
||||
)
|
||||
}
|
||||
402
frontend/src/pages/AdminPanel.jsx
Normal file
402
frontend/src/pages/AdminPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
425
frontend/src/pages/Analysis.jsx
Normal file
425
frontend/src/pages/Analysis.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
frontend/src/pages/CaliperScreen.jsx
Normal file
173
frontend/src/pages/CaliperScreen.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
frontend/src/pages/CaptureHub.jsx
Normal file
98
frontend/src/pages/CaptureHub.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
frontend/src/pages/CircumScreen.jsx
Normal file
165
frontend/src/pages/CircumScreen.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
461
frontend/src/pages/Dashboard.jsx
Normal file
461
frontend/src/pages/Dashboard.jsx
Normal 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,6–2,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>
|
||||
)
|
||||
}
|
||||
114
frontend/src/pages/GuidePage.jsx
Normal file
114
frontend/src/pages/GuidePage.jsx
Normal 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 1–2 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>
|
||||
)
|
||||
}
|
||||
961
frontend/src/pages/History.jsx
Normal file
961
frontend/src/pages/History.jsx
Normal 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 <{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 <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,6–2,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: 25–35%. 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: 4–5 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>
|
||||
)
|
||||
}
|
||||
116
frontend/src/pages/LoginScreen.jsx
Normal file
116
frontend/src/pages/LoginScreen.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
410
frontend/src/pages/MeasureWizard.jsx
Normal file
410
frontend/src/pages/MeasureWizard.jsx
Normal 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}}>
|
||||
3–9 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>
|
||||
)
|
||||
}
|
||||
215
frontend/src/pages/NewMeasurement.jsx
Normal file
215
frontend/src/pages/NewMeasurement.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
434
frontend/src/pages/NutritionPage.jsx
Normal file
434
frontend/src/pages/NutritionPage.jsx
Normal 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; 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,6–2,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 ~1–2 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,6–2,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>
|
||||
)
|
||||
}
|
||||
126
frontend/src/pages/PasswordRecovery.jsx
Normal file
126
frontend/src/pages/PasswordRecovery.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
frontend/src/pages/ProfileSelect.jsx
Normal file
124
frontend/src/pages/ProfileSelect.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
319
frontend/src/pages/SettingsPage.jsx
Normal file
319
frontend/src/pages/SettingsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
frontend/src/pages/SetupScreen.jsx
Normal file
150
frontend/src/pages/SetupScreen.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
frontend/src/pages/WeightScreen.jsx
Normal file
170
frontend/src/pages/WeightScreen.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
frontend/src/utils/Markdown.jsx
Normal file
133
frontend/src/utils/Markdown.jsx
Normal 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
98
frontend/src/utils/api.js
Normal 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
279
frontend/src/utils/calc.js
Normal 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 2–3 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. 15–20 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:'1–2 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' },
|
||||
}
|
||||
120
frontend/src/utils/guideData.js
Normal file
120
frontend/src/utils/guideData.js
Normal 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 2–3 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. 15–20 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: '1–2 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'] },
|
||||
}
|
||||
184
frontend/src/utils/interpret.js
Normal file
184
frontend/src/utils/interpret.js
Normal 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,5–24,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
37
frontend/vite.config.js
Normal 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
34
nginx/certbot-setup.sh
Normal 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
67
nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user