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, "body_battery_hires": metric.body_battery_hires 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"}