feat: implement Vitals module (Ruhepuls + HRV)
Backend:
- New router: vitals.py with CRUD endpoints
- GET /api/vitals (list)
- GET /api/vitals/by-date/{date}
- POST /api/vitals (upsert)
- PUT /api/vitals/{id}
- DELETE /api/vitals/{id}
- GET /api/vitals/stats (7d/30d averages, trends)
- Registered in main.py
Frontend:
- VitalsPage.jsx with manual entry form
- List with inline editing
- Stats overview (averages, trend indicators)
- Added to CaptureHub (❤️ icon)
- Route /vitals in App.jsx
API:
- Added vitals methods to api.js
v9d Phase 2d - Vitals tracking complete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5bd1b33f5a
commit
4191c52298
|
|
@ -20,7 +20,7 @@ from routers import activity, nutrition, photos, insights, prompts
|
||||||
from routers import admin, stats, exportdata, importdata
|
from routers import admin, stats, exportdata, importdata
|
||||||
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
||||||
from routers import user_restrictions, access_grants, training_types, admin_training_types
|
from routers import user_restrictions, access_grants, training_types, admin_training_types
|
||||||
from routers import admin_activity_mappings, sleep, rest_days
|
from routers import admin_activity_mappings, sleep, rest_days, vitals
|
||||||
from routers import evaluation # v9d/v9e Training Type Profiles (#15)
|
from routers import evaluation # v9d/v9e Training Type Profiles (#15)
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -87,12 +87,13 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin)
|
||||||
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
|
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
|
||||||
app.include_router(access_grants.router) # /api/access-grants (admin)
|
app.include_router(access_grants.router) # /api/access-grants (admin)
|
||||||
|
|
||||||
# v9d Training Types & Sleep Module & Rest Days
|
# v9d Training Types & Sleep Module & Rest Days & Vitals
|
||||||
app.include_router(training_types.router) # /api/training-types/*
|
app.include_router(training_types.router) # /api/training-types/*
|
||||||
app.include_router(admin_training_types.router) # /api/admin/training-types/*
|
app.include_router(admin_training_types.router) # /api/admin/training-types/*
|
||||||
app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/*
|
app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/*
|
||||||
app.include_router(sleep.router) # /api/sleep/* (v9d Phase 2b)
|
app.include_router(sleep.router) # /api/sleep/* (v9d Phase 2b)
|
||||||
app.include_router(rest_days.router) # /api/rest-days/* (v9d Phase 2a)
|
app.include_router(rest_days.router) # /api/rest-days/* (v9d Phase 2a)
|
||||||
|
app.include_router(vitals.router) # /api/vitals/* (v9d Phase 2d)
|
||||||
app.include_router(evaluation.router) # /api/evaluation/* (v9d/v9e Training Profiles #15)
|
app.include_router(evaluation.router) # /api/evaluation/* (v9d/v9e Training Profiles #15)
|
||||||
|
|
||||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
318
backend/routers/vitals.py
Normal file
318
backend/routers/vitals.py
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
"""
|
||||||
|
Vitals Router - Resting HR + HRV Tracking
|
||||||
|
v9d Phase 2: Vitals Module
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /api/vitals List vitals (with limit)
|
||||||
|
- GET /api/vitals/by-date/{date} Get vitals for specific date
|
||||||
|
- POST /api/vitals Create/update vitals (upsert)
|
||||||
|
- PUT /api/vitals/{id} Update vitals
|
||||||
|
- DELETE /api/vitals/{id} Delete vitals
|
||||||
|
- GET /api/vitals/stats Get vitals statistics
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Header
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from db import get_db, get_cursor, r2d
|
||||||
|
from auth import require_auth
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/vitals", tags=["vitals"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VitalsEntry(BaseModel):
|
||||||
|
date: str
|
||||||
|
resting_hr: Optional[int] = None
|
||||||
|
hrv: Optional[int] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VitalsUpdate(BaseModel):
|
||||||
|
date: Optional[str] = None
|
||||||
|
resting_hr: Optional[int] = None
|
||||||
|
hrv: Optional[int] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pid(x_profile_id: Optional[str], session: dict) -> str:
|
||||||
|
"""Extract profile_id from session (never from header for security)."""
|
||||||
|
return session['profile_id']
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_vitals(
|
||||||
|
limit: int = 90,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth)
|
||||||
|
):
|
||||||
|
"""Get vitals entries for current profile."""
|
||||||
|
pid = get_pid(x_profile_id, session)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, profile_id, date, resting_hr, hrv, note, source,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM vitals_log
|
||||||
|
WHERE profile_id = %s
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(pid, limit)
|
||||||
|
)
|
||||||
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-date/{date}")
|
||||||
|
def get_vitals_by_date(
|
||||||
|
date: str,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth)
|
||||||
|
):
|
||||||
|
"""Get vitals entry for a specific date."""
|
||||||
|
pid = get_pid(x_profile_id, session)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, profile_id, date, resting_hr, hrv, note, source,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM vitals_log
|
||||||
|
WHERE profile_id = %s AND date = %s
|
||||||
|
""",
|
||||||
|
(pid, date)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Keine Vitalwerte für dieses Datum gefunden")
|
||||||
|
return r2d(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_vitals(
|
||||||
|
entry: VitalsEntry,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth)
|
||||||
|
):
|
||||||
|
"""Create or update vitals entry (upsert)."""
|
||||||
|
pid = get_pid(x_profile_id, session)
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if entry.resting_hr is None and entry.hrv is None:
|
||||||
|
raise HTTPException(400, "Mindestens Ruhepuls oder HRV muss angegeben werden")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Upsert: insert or update if date already exists
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO vitals_log (profile_id, date, resting_hr, hrv, note, source)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, 'manual')
|
||||||
|
ON CONFLICT (profile_id, date)
|
||||||
|
DO UPDATE SET
|
||||||
|
resting_hr = EXCLUDED.resting_hr,
|
||||||
|
hrv = EXCLUDED.hrv,
|
||||||
|
note = EXCLUDED.note,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id, profile_id, date, resting_hr, hrv, note, source, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(pid, entry.date, entry.resting_hr, entry.hrv, entry.note)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"[VITALS] Upserted vitals for {pid} on {entry.date}")
|
||||||
|
return r2d(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{vitals_id}")
|
||||||
|
def update_vitals(
|
||||||
|
vitals_id: int,
|
||||||
|
updates: VitalsUpdate,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth)
|
||||||
|
):
|
||||||
|
"""Update existing vitals entry."""
|
||||||
|
pid = get_pid(x_profile_id, session)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Check ownership
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM vitals_log WHERE id = %s AND profile_id = %s",
|
||||||
|
(vitals_id, pid)
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||||
|
|
||||||
|
# Build update query dynamically
|
||||||
|
fields = []
|
||||||
|
values = []
|
||||||
|
|
||||||
|
if updates.date is not None:
|
||||||
|
fields.append("date = %s")
|
||||||
|
values.append(updates.date)
|
||||||
|
if updates.resting_hr is not None:
|
||||||
|
fields.append("resting_hr = %s")
|
||||||
|
values.append(updates.resting_hr)
|
||||||
|
if updates.hrv is not None:
|
||||||
|
fields.append("hrv = %s")
|
||||||
|
values.append(updates.hrv)
|
||||||
|
if updates.note is not None:
|
||||||
|
fields.append("note = %s")
|
||||||
|
values.append(updates.note)
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(400, "Keine Änderungen angegeben")
|
||||||
|
|
||||||
|
fields.append("updated_at = CURRENT_TIMESTAMP")
|
||||||
|
values.append(vitals_id)
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
UPDATE vitals_log
|
||||||
|
SET {', '.join(fields)}
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, profile_id, date, resting_hr, hrv, note, source, created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, values)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return r2d(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{vitals_id}")
|
||||||
|
def delete_vitals(
|
||||||
|
vitals_id: int,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth)
|
||||||
|
):
|
||||||
|
"""Delete vitals entry."""
|
||||||
|
pid = get_pid(x_profile_id, session)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Check ownership and delete
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM vitals_log WHERE id = %s AND profile_id = %s RETURNING id",
|
||||||
|
(vitals_id, pid)
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"[VITALS] Deleted vitals {vitals_id} for {pid}")
|
||||||
|
return {"message": "Eintrag gelöscht"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def get_vitals_stats(
|
||||||
|
days: int = 30,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get vitals statistics over the last N days.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- avg_resting_hr (7d and 30d)
|
||||||
|
- avg_hrv (7d and 30d)
|
||||||
|
- trend (increasing/decreasing/stable)
|
||||||
|
- latest values
|
||||||
|
"""
|
||||||
|
pid = get_pid(x_profile_id, session)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Get latest entry
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT date, resting_hr, hrv
|
||||||
|
FROM vitals_log
|
||||||
|
WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '%s days'
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(pid, days)
|
||||||
|
)
|
||||||
|
latest = cur.fetchone()
|
||||||
|
|
||||||
|
# Get averages (7d and 30d)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN resting_hr END) as avg_hr_7d,
|
||||||
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN resting_hr END) as avg_hr_30d,
|
||||||
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN hrv END) as avg_hrv_7d,
|
||||||
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN hrv END) as avg_hrv_30d,
|
||||||
|
COUNT(*) as total_entries
|
||||||
|
FROM vitals_log
|
||||||
|
WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '%s days'
|
||||||
|
""",
|
||||||
|
(pid, max(days, 30))
|
||||||
|
)
|
||||||
|
stats_row = cur.fetchone()
|
||||||
|
|
||||||
|
# Get entries for trend calculation (last 14 days)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT date, resting_hr, hrv
|
||||||
|
FROM vitals_log
|
||||||
|
WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '14 days'
|
||||||
|
ORDER BY date ASC
|
||||||
|
""",
|
||||||
|
(pid,)
|
||||||
|
)
|
||||||
|
entries = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
# Simple trend calculation (compare first half vs second half)
|
||||||
|
trend_hr = "stable"
|
||||||
|
trend_hrv = "stable"
|
||||||
|
|
||||||
|
if len(entries) >= 4:
|
||||||
|
mid = len(entries) // 2
|
||||||
|
first_half_hr = [e['resting_hr'] for e in entries[:mid] if e['resting_hr']]
|
||||||
|
second_half_hr = [e['resting_hr'] for e in entries[mid:] if e['resting_hr']]
|
||||||
|
|
||||||
|
if first_half_hr and second_half_hr:
|
||||||
|
avg_first = sum(first_half_hr) / len(first_half_hr)
|
||||||
|
avg_second = sum(second_half_hr) / len(second_half_hr)
|
||||||
|
diff = avg_second - avg_first
|
||||||
|
|
||||||
|
if diff > 2:
|
||||||
|
trend_hr = "increasing"
|
||||||
|
elif diff < -2:
|
||||||
|
trend_hr = "decreasing"
|
||||||
|
|
||||||
|
first_half_hrv = [e['hrv'] for e in entries[:mid] if e['hrv']]
|
||||||
|
second_half_hrv = [e['hrv'] for e in entries[mid:] if e['hrv']]
|
||||||
|
|
||||||
|
if first_half_hrv and second_half_hrv:
|
||||||
|
avg_first_hrv = sum(first_half_hrv) / len(first_half_hrv)
|
||||||
|
avg_second_hrv = sum(second_half_hrv) / len(second_half_hrv)
|
||||||
|
diff_hrv = avg_second_hrv - avg_first_hrv
|
||||||
|
|
||||||
|
if diff_hrv > 5:
|
||||||
|
trend_hrv = "increasing"
|
||||||
|
elif diff_hrv < -5:
|
||||||
|
trend_hrv = "decreasing"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"latest": r2d(latest) if latest else None,
|
||||||
|
"avg_resting_hr_7d": round(stats_row['avg_hr_7d'], 1) if stats_row['avg_hr_7d'] else None,
|
||||||
|
"avg_resting_hr_30d": round(stats_row['avg_hr_30d'], 1) if stats_row['avg_hr_30d'] else None,
|
||||||
|
"avg_hrv_7d": round(stats_row['avg_hrv_7d'], 1) if stats_row['avg_hrv_7d'] else None,
|
||||||
|
"avg_hrv_30d": round(stats_row['avg_hrv_30d'], 1) if stats_row['avg_hrv_30d'] else None,
|
||||||
|
"total_entries": stats_row['total_entries'],
|
||||||
|
"trend_resting_hr": trend_hr,
|
||||||
|
"trend_hrv": trend_hrv,
|
||||||
|
"period_days": days
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
|
||||||
import SubscriptionPage from './pages/SubscriptionPage'
|
import SubscriptionPage from './pages/SubscriptionPage'
|
||||||
import SleepPage from './pages/SleepPage'
|
import SleepPage from './pages/SleepPage'
|
||||||
import RestDaysPage from './pages/RestDaysPage'
|
import RestDaysPage from './pages/RestDaysPage'
|
||||||
|
import VitalsPage from './pages/VitalsPage'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
function Nav() {
|
function Nav() {
|
||||||
|
|
@ -169,6 +170,7 @@ function AppShell() {
|
||||||
<Route path="/history" element={<History/>}/>
|
<Route path="/history" element={<History/>}/>
|
||||||
<Route path="/sleep" element={<SleepPage/>}/>
|
<Route path="/sleep" element={<SleepPage/>}/>
|
||||||
<Route path="/rest-days" element={<RestDaysPage/>}/>
|
<Route path="/rest-days" element={<RestDaysPage/>}/>
|
||||||
|
<Route path="/vitals" element={<VitalsPage/>}/>
|
||||||
<Route path="/nutrition" element={<NutritionPage/>}/>
|
<Route path="/nutrition" element={<NutritionPage/>}/>
|
||||||
<Route path="/activity" element={<ActivityPage/>}/>
|
<Route path="/activity" element={<ActivityPage/>}/>
|
||||||
<Route path="/analysis" element={<Analysis/>}/>
|
<Route path="/analysis" element={<Analysis/>}/>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,13 @@ const ENTRIES = [
|
||||||
to: '/rest-days',
|
to: '/rest-days',
|
||||||
color: '#9B59B6',
|
color: '#9B59B6',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: '❤️',
|
||||||
|
label: 'Vitalwerte',
|
||||||
|
sub: 'Ruhepuls und HRV morgens erfassen',
|
||||||
|
to: '/vitals',
|
||||||
|
color: '#E74C3C',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: '📖',
|
icon: '📖',
|
||||||
label: 'Messanleitung',
|
label: 'Messanleitung',
|
||||||
|
|
|
||||||
416
frontend/src/pages/VitalsPage.jsx
Normal file
416
frontend/src/pages/VitalsPage.jsx
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Pencil, Trash2, X, TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import 'dayjs/locale/de'
|
||||||
|
dayjs.locale('de')
|
||||||
|
|
||||||
|
function empty() {
|
||||||
|
return {
|
||||||
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
resting_hr: '',
|
||||||
|
hrv: '',
|
||||||
|
note: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryForm({ form, setForm, onSave, onCancel, saving, 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">Ruhepuls *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min={30}
|
||||||
|
max={120}
|
||||||
|
step={1}
|
||||||
|
placeholder="z.B. 58"
|
||||||
|
value={form.resting_hr || ''}
|
||||||
|
onChange={e => set('resting_hr', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="form-unit">bpm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">HRV</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min={1}
|
||||||
|
max={300}
|
||||||
|
step={1}
|
||||||
|
placeholder="optional"
|
||||||
|
value={form.hrv || ''}
|
||||||
|
onChange={e => set('hrv', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="form-unit">ms</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Notiz</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="optional"
|
||||||
|
value={form.note || ''}
|
||||||
|
onChange={e => set('note', 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}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
{onCancel && (
|
||||||
|
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
|
||||||
|
<X size={13} /> Abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VitalsPage() {
|
||||||
|
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 [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [e, s] = await Promise.all([api.listVitals(90), api.getVitalsStats(30)])
|
||||||
|
setEntries(e)
|
||||||
|
setStats(s)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load failed:', err)
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = { ...form }
|
||||||
|
if (payload.resting_hr) payload.resting_hr = parseInt(payload.resting_hr)
|
||||||
|
if (payload.hrv) payload.hrv = parseInt(payload.hrv)
|
||||||
|
|
||||||
|
if (!payload.resting_hr && !payload.hrv) {
|
||||||
|
setError('Mindestens Ruhepuls oder HRV muss angegeben werden')
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.createVitals(payload)
|
||||||
|
setSaved(true)
|
||||||
|
await load()
|
||||||
|
setTimeout(() => {
|
||||||
|
setSaved(false)
|
||||||
|
setForm(empty())
|
||||||
|
}, 1500)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err)
|
||||||
|
setError(err.message || 'Fehler beim Speichern')
|
||||||
|
setTimeout(() => setError(null), 5000)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
try {
|
||||||
|
const payload = { ...editing }
|
||||||
|
if (payload.resting_hr) payload.resting_hr = parseInt(payload.resting_hr)
|
||||||
|
if (payload.hrv) payload.hrv = parseInt(payload.hrv)
|
||||||
|
|
||||||
|
await api.updateVitals(editing.id, payload)
|
||||||
|
setEditing(null)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update failed:', err)
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('Eintrag löschen?')) return
|
||||||
|
try {
|
||||||
|
await api.deleteVitals(id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err)
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTrendIcon = (trend) => {
|
||||||
|
if (trend === 'increasing') return <TrendingUp size={14} style={{ color: '#D85A30' }} />
|
||||||
|
if (trend === 'decreasing') return <TrendingDown size={14} style={{ color: '#1D9E75' }} />
|
||||||
|
return <Minus size={14} style={{ color: 'var(--text3)' }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Vitalwerte</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')}>
|
||||||
|
+ Erfassen
|
||||||
|
</button>
|
||||||
|
<button className={'tab' + (tab === 'stats' ? ' active' : '')} onClick={() => setTab('stats')}>
|
||||||
|
Statistik
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Overview */}
|
||||||
|
{stats && stats.total_entries > 0 && (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 10px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#378ADD' }}>
|
||||||
|
{stats.avg_resting_hr_7d ? Math.round(stats.avg_resting_hr_7d) : '—'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø Ruhepuls 7d</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 10px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#1D9E75' }}>
|
||||||
|
{stats.avg_hrv_7d ? Math.round(stats.avg_hrv_7d) : '—'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø HRV 7d</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 10px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text2)' }}>
|
||||||
|
{stats.total_entries}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Einträge</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'add' && (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Vitalwerte erfassen</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 10, lineHeight: 1.6 }}>
|
||||||
|
Morgens nach dem Aufwachen, vor dem Aufstehen: Ruhepuls messen (z.B. mit Apple Watch oder Smartwatch).
|
||||||
|
HRV ist optional.
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px',
|
||||||
|
background: '#FCEBEB',
|
||||||
|
border: '1px solid #D85A30',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#D85A30',
|
||||||
|
marginBottom: 8
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<EntryForm
|
||||||
|
form={form}
|
||||||
|
setForm={setForm}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={saved ? '✓ Gespeichert!' : 'Speichern'}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'stats' && stats && (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Trend-Analyse (14 Tage)</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>Ruhepuls</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{getTrendIcon(stats.trend_resting_hr)}
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
|
||||||
|
{stats.trend_resting_hr === 'increasing' ? 'Steigend' :
|
||||||
|
stats.trend_resting_hr === 'decreasing' ? 'Sinkend' : 'Stabil'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 0'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>HRV</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{getTrendIcon(stats.trend_hrv)}
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
|
||||||
|
{stats.trend_hrv === 'increasing' ? 'Steigend' :
|
||||||
|
stats.trend_hrv === 'decreasing' ? 'Sinkend' : 'Stabil'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: 12,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text2)',
|
||||||
|
lineHeight: 1.6
|
||||||
|
}}>
|
||||||
|
<strong>Interpretation:</strong><br />
|
||||||
|
• Ruhepuls sinkend = bessere Fitness 💪<br />
|
||||||
|
• HRV steigend = bessere Erholung ✨<br />
|
||||||
|
• Ruhepuls steigend + HRV sinkend = Übertraining-Signal ⚠️
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'list' && (
|
||||||
|
<div>
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>Keine Einträge</h3>
|
||||||
|
<p>Erfasse deine ersten Vitalwerte im Tab "Erfassen".</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entries.map(e => {
|
||||||
|
const isEd = editing?.id === e.id
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="card" style={{ marginBottom: 8 }}>
|
||||||
|
{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, marginBottom: 2 }}>
|
||||||
|
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 6 }}>
|
||||||
|
{e.resting_hr && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||||
|
❤️ {e.resting_hr} bpm
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{e.hrv && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||||
|
📊 HRV {e.hrv} ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{e.source !== 'manual' && (
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text3)' }}>
|
||||||
|
{e.source === 'apple_health' ? 'Apple Health' : e.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{e.note && (
|
||||||
|
<p style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text2)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
marginTop: 4
|
||||||
|
}}>
|
||||||
|
"{e.note}"
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -256,4 +256,12 @@ export const api = {
|
||||||
deleteRestDay: (id) => req(`/rest-days/${id}`, {method:'DELETE'}),
|
deleteRestDay: (id) => req(`/rest-days/${id}`, {method:'DELETE'}),
|
||||||
getRestDaysStats: (weeks=4) => req(`/rest-days/stats?weeks=${weeks}`),
|
getRestDaysStats: (weeks=4) => req(`/rest-days/stats?weeks=${weeks}`),
|
||||||
validateActivity: (date, activityType) => req('/rest-days/validate-activity', json({date, activity_type: activityType})),
|
validateActivity: (date, activityType) => req('/rest-days/validate-activity', json({date, activity_type: activityType})),
|
||||||
|
|
||||||
|
// Vitals (v9d Phase 2d)
|
||||||
|
listVitals: (l=90) => req(`/vitals?limit=${l}`),
|
||||||
|
getVitalsByDate: (date) => req(`/vitals/by-date/${date}`),
|
||||||
|
createVitals: (d) => req('/vitals', json(d)),
|
||||||
|
updateVitals: (id,d) => req(`/vitals/${id}`, jput(d)),
|
||||||
|
deleteVitals: (id) => req(`/vitals/${id}`, {method:'DELETE'}),
|
||||||
|
getVitalsStats: (days=30) => req(`/vitals/stats?days=${days}`),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user