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 subscription, coupons, features, tiers_mgmt, tier_limits
|
||||
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)
|
||||
|
||||
# ── 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(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(admin_training_types.router) # /api/admin/training-types/*
|
||||
app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/*
|
||||
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(vitals.router) # /api/vitals/* (v9d Phase 2d)
|
||||
app.include_router(evaluation.router) # /api/evaluation/* (v9d/v9e Training Profiles #15)
|
||||
|
||||
# ── 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 SleepPage from './pages/SleepPage'
|
||||
import RestDaysPage from './pages/RestDaysPage'
|
||||
import VitalsPage from './pages/VitalsPage'
|
||||
import './app.css'
|
||||
|
||||
function Nav() {
|
||||
|
|
@ -169,6 +170,7 @@ function AppShell() {
|
|||
<Route path="/history" element={<History/>}/>
|
||||
<Route path="/sleep" element={<SleepPage/>}/>
|
||||
<Route path="/rest-days" element={<RestDaysPage/>}/>
|
||||
<Route path="/vitals" element={<VitalsPage/>}/>
|
||||
<Route path="/nutrition" element={<NutritionPage/>}/>
|
||||
<Route path="/activity" element={<ActivityPage/>}/>
|
||||
<Route path="/analysis" element={<Analysis/>}/>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,13 @@ const ENTRIES = [
|
|||
to: '/rest-days',
|
||||
color: '#9B59B6',
|
||||
},
|
||||
{
|
||||
icon: '❤️',
|
||||
label: 'Vitalwerte',
|
||||
sub: 'Ruhepuls und HRV morgens erfassen',
|
||||
to: '/vitals',
|
||||
color: '#E74C3C',
|
||||
},
|
||||
{
|
||||
icon: '📖',
|
||||
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'}),
|
||||
getRestDaysStats: (weeks=4) => req(`/rest-days/stats?weeks=${weeks}`),
|
||||
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