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>
319 lines
10 KiB
Python
319 lines
10 KiB
Python
"""
|
|
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
|
|
}
|