feat: implement Vitals module (Ruhepuls + HRV)
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

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:
Lars 2026-03-23 14:52:09 +01:00
parent 5bd1b33f5a
commit 4191c52298
6 changed files with 754 additions and 2 deletions

View File

@ -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
View 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
}

View File

@ -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/>}/>

View File

@ -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',

View 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>
)
}

View File

@ -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}`),
}