616099402b
Parses Garmin Connect get_body_battery() per day, storing charged/drained/ start+end levels and the fine-grained [[ts_ms, level, type, stress]] values array in a new body_battery JSONB column on health_metrics. Frontend adds: - BatteryRing SVG gauge (color-scaled 0–100) - BodyBatteryChart: ComposedChart with type-colored bars (REST/ACTIVE/SLEEP/ STRESS) and battery level overlay line, matching Garmin's layout - Body battery trend chart in the Trends section (end_level per day) Also adds avg_hr_day and weight data which now correctly sync with the intraday_hr JSON serialization fix from the previous commit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
185 lines
6.1 KiB
Python
185 lines
6.1 KiB
Python
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, desc, func
|
|
from pydantic import BaseModel, model_validator
|
|
from typing import Optional, List, Any
|
|
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]
|
|
body_battery: Optional[Any] = None # {charged,drained,start_level,end_level} — values stripped
|
|
|
|
@model_validator(mode='after')
|
|
def _strip_bb_values(self):
|
|
if isinstance(self.body_battery, dict):
|
|
self.body_battery = {k: v for k, v in self.body_battery.items() if k != 'values'}
|
|
return self
|
|
|
|
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,
|
|
"body_battery": metric.body_battery 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"} |