Files
MileVault/backend/app/api/health.py
T
owain f927e32853
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
Fix missing avg_hr_day/weight data; add 24hr HR chart to daily snapshot
Backend:
- main.py: add ADD COLUMN IF NOT EXISTS migrations for avg_hr_day, max_hr_day,
  and intraday_hr (JSONB) on health_metrics — these columns were in the model
  but missing from existing DB instances, silently dropping all avg/max HR data.
- models/user.py: add intraday_hr JSON column to HealthMetric.
- garmin_connect_sync.py: fetch body composition (weight, BMI, body fat, muscle
  mass) via get_body_composition() per day, with stats.bodyWeight as fallback.
  Fetch intraday heart rate via get_heart_rates() and store non-null
  [epoch_ms, bpm] pairs in intraday_hr.
- health.py: add GET /health-metrics/intraday?date=YYYY-MM-DD endpoint that
  returns the stored intraday_hr array for a specific day.

Frontend (HealthPage):
- Add IntradayHrChart component: AreaChart rendering the 24-hour HR trace
  with time-of-day x-axis.
- DailySnapshot: show 24-hour HR chart (when intraday data present) above
  the activity strip; add weight + body fat % to the Heart & HRV card;
  show max HR alongside avg HR.
- HealthPage: query /intraday for the selected day and pass data down.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:47:53 +01:00

175 lines
5.7 KiB
Python

from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, HealthMetric
router = APIRouter()
class HealthMetricOut(BaseModel):
id: int
date: datetime
resting_hr: Optional[float]
max_hr_day: Optional[float]
avg_hr_day: Optional[float]
hrv_nightly_avg: Optional[float]
hrv_status: Optional[str]
hrv_5min_high: Optional[float]
hrv_5min_low: Optional[float]
sleep_duration_s: Optional[float]
sleep_deep_s: Optional[float]
sleep_light_s: Optional[float]
sleep_rem_s: Optional[float]
sleep_awake_s: Optional[float]
sleep_score: Optional[float]
sleep_start: Optional[datetime]
sleep_end: Optional[datetime]
weight_kg: Optional[float]
bmi: Optional[float]
body_fat_pct: Optional[float]
muscle_mass_kg: Optional[float]
vo2max: Optional[float]
fitness_age: Optional[int]
training_load: Optional[float]
recovery_time_h: Optional[float]
avg_stress: Optional[float]
steps: Optional[int]
floors_climbed: Optional[int]
active_calories: Optional[float]
total_calories: Optional[float]
spo2_avg: Optional[float]
class Config:
from_attributes = True
@router.get("/", response_model=List[HealthMetricOut])
async def list_health_metrics(
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
limit: int = Query(365, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
if from_date:
from_date_naive = from_date.replace(tzinfo=None) if from_date.tzinfo else from_date
q = q.where(func.date(HealthMetric.date) >= from_date_naive.date())
if to_date:
to_date_naive = to_date.replace(tzinfo=None) if to_date.tzinfo else to_date
q = q.where(func.date(HealthMetric.date) <= to_date_naive.date())
q = q.order_by(desc(HealthMetric.date)).limit(limit)
result = await db.execute(q)
return result.scalars().all()
@router.get("/summary")
async def health_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
latest_result = await db.execute(
select(HealthMetric)
.where(HealthMetric.user_id == current_user.id)
.order_by(desc(HealthMetric.date))
.limit(1)
)
latest = latest_result.scalar_one_or_none()
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
avg_result = await db.execute(
select(
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
func.avg(HealthMetric.hrv_nightly_avg).label("avg_hrv"),
func.avg(HealthMetric.sleep_duration_s).label("avg_sleep_s"),
func.avg(HealthMetric.sleep_score).label("avg_sleep_score"),
func.avg(HealthMetric.avg_stress).label("avg_stress"),
func.avg(HealthMetric.steps).label("avg_steps"),
func.avg(HealthMetric.weight_kg).label("avg_weight"),
).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) >= cutoff,
)
)
avgs = avg_result.one()
return {
"latest": HealthMetricOut.model_validate(latest) if latest else None,
"avg_30d": {
"resting_hr": avgs.avg_resting_hr,
"hrv": avgs.avg_hrv,
"sleep_h": (avgs.avg_sleep_s / 3600) if avgs.avg_sleep_s else None,
"sleep_score": avgs.avg_sleep_score,
"stress": avgs.avg_stress,
"steps": avgs.avg_steps,
"weight_kg": avgs.avg_weight,
},
}
@router.get("/intraday")
async def intraday_health(
date: str = Query(..., description="YYYY-MM-DD"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return intraday heart rate series for a specific day."""
from datetime import date as _date
from fastapi import HTTPException
try:
metric_date = _date.fromisoformat(date)
except ValueError:
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
result = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) == metric_date,
)
)
metric = result.scalar_one_or_none()
return {"hr_values": metric.intraday_hr if metric else None}
@router.put("/manual")
async def add_manual_metric(
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from fastapi import HTTPException
date_str = body.get("date")
if not date_str:
raise HTTPException(status_code=400, detail="date required")
metric_date = datetime.fromisoformat(date_str)
existing = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) == metric_date.date(),
)
)
metric = existing.scalar_one_or_none()
if metric:
for key, val in body.items():
if hasattr(metric, key) and key not in ("id", "user_id"):
setattr(metric, key, val)
else:
metric = HealthMetric(user_id=current_user.id, date=metric_date, **{
k: v for k, v in body.items()
if hasattr(HealthMetric, k) and k not in ("id", "user_id")
})
db.add(metric)
await db.commit()
return {"status": "ok"}