From 4191c52298239e4633f1c3988e443f2c9808b7fa Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 23 Mar 2026 14:52:09 +0100 Subject: [PATCH] feat: implement Vitals module (Ruhepuls + HRV) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/main.py | 5 +- backend/routers/vitals.py | 318 +++++++++++++++++++++++ frontend/src/App.jsx | 2 + frontend/src/pages/CaptureHub.jsx | 7 + frontend/src/pages/VitalsPage.jsx | 416 ++++++++++++++++++++++++++++++ frontend/src/utils/api.js | 8 + 6 files changed, 754 insertions(+), 2 deletions(-) create mode 100644 backend/routers/vitals.py create mode 100644 frontend/src/pages/VitalsPage.jsx diff --git a/backend/main.py b/backend/main.py index a7689ec..1fcfeac 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 ────────────────────────────────────────────────────────────── diff --git a/backend/routers/vitals.py b/backend/routers/vitals.py new file mode 100644 index 0000000..abeb1e6 --- /dev/null +++ b/backend/routers/vitals.py @@ -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 + } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index eb41e9d..7e0d145 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/pages/CaptureHub.jsx b/frontend/src/pages/CaptureHub.jsx index cdfb45f..40cadc1 100644 --- a/frontend/src/pages/CaptureHub.jsx +++ b/frontend/src/pages/CaptureHub.jsx @@ -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', diff --git a/frontend/src/pages/VitalsPage.jsx b/frontend/src/pages/VitalsPage.jsx new file mode 100644 index 0000000..4e17474 --- /dev/null +++ b/frontend/src/pages/VitalsPage.jsx @@ -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 ( +
+
+ + set('date', e.target.value)} + /> + +
+ +
+ + set('resting_hr', e.target.value)} + /> + bpm +
+ +
+ + set('hrv', e.target.value)} + /> + ms +
+ +
+ + set('note', e.target.value)} + /> + +
+ +
+ + {onCancel && ( + + )} +
+
+ ) +} + +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 + if (trend === 'decreasing') return + return + } + + return ( +
+

Vitalwerte

+ +
+ + + +
+ + {/* Stats Overview */} + {stats && stats.total_entries > 0 && ( +
+
+
+
+ {stats.avg_resting_hr_7d ? Math.round(stats.avg_resting_hr_7d) : '—'} +
+
Ø Ruhepuls 7d
+
+
+
+ {stats.avg_hrv_7d ? Math.round(stats.avg_hrv_7d) : '—'} +
+
Ø HRV 7d
+
+
+
+ {stats.total_entries} +
+
Einträge
+
+
+
+ )} + + {tab === 'add' && ( +
+
Vitalwerte erfassen
+

+ Morgens nach dem Aufwachen, vor dem Aufstehen: Ruhepuls messen (z.B. mit Apple Watch oder Smartwatch). + HRV ist optional. +

+ {error && ( +
+ {error} +
+ )} + +
+ )} + + {tab === 'stats' && stats && ( +
+
Trend-Analyse (14 Tage)
+ +
+
+
Ruhepuls
+
+ {getTrendIcon(stats.trend_resting_hr)} + + {stats.trend_resting_hr === 'increasing' ? 'Steigend' : + stats.trend_resting_hr === 'decreasing' ? 'Sinkend' : 'Stabil'} + +
+
+ +
+
HRV
+
+ {getTrendIcon(stats.trend_hrv)} + + {stats.trend_hrv === 'increasing' ? 'Steigend' : + stats.trend_hrv === 'decreasing' ? 'Sinkend' : 'Stabil'} + +
+
+
+ +
+ Interpretation:
+ • Ruhepuls sinkend = bessere Fitness 💪
+ • HRV steigend = bessere Erholung ✨
+ • Ruhepuls steigend + HRV sinkend = Übertraining-Signal ⚠️ +
+
+ )} + + {tab === 'list' && ( +
+ {entries.length === 0 && ( +
+

Keine Einträge

+

Erfasse deine ersten Vitalwerte im Tab "Erfassen".

+
+ )} + {entries.map(e => { + const isEd = editing?.id === e.id + return ( +
+ {isEd ? ( + setEditing(null)} + saveLabel="Speichern" + /> + ) : ( +
+
+
+
+ {dayjs(e.date).format('dd, DD. MMMM YYYY')} +
+
+ {e.resting_hr && ( + + ❤️ {e.resting_hr} bpm + + )} + {e.hrv && ( + + 📊 HRV {e.hrv} ms + + )} + {e.source !== 'manual' && ( + + {e.source === 'apple_health' ? 'Apple Health' : e.source} + + )} +
+ {e.note && ( +

+ "{e.note}" +

+ )} +
+
+ + +
+
+
+ )} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index c840cd1..37ee80a 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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}`), }