Files
2026-07-30 23:25:20 -04:00

599 lines
22 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, distinct
from typing import List, Optional, Dict, Any
from datetime import datetime, date, timedelta
import logging
from backend.database import get_db
from backend.models.user import User
from backend.models.health_connect_raw import (
HCRawSteps,
HCRawHeartRate,
HCRawHeartRateSample,
HCRawRestingHeartRate,
HCRawSleepSession,
HCRawSleepStage,
HCRawDistance,
HCRawCalories,
HCRawOxygenSaturation,
HCRawWeight,
HCRawExerciseSession,
HCRawHeartRateVariabilityRmssd,
SLEEP_STAGE_TYPES
)
from backend.models.daily_context import DailyContext
from pydantic import BaseModel
from backend import auth
router = APIRouter(prefix="/api/analytics", tags=["health-analytics"])
logger = logging.getLogger(__name__)
@router.get("/sources", response_model=List[Dict[str, str]])
async def get_available_sources(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get unique data sources available in the raw data.
Checks Steps and Sleep as primary indicators.
"""
# Query distinct sources from Steps
step_sources = db.query(
HCRawSteps.data_source,
HCRawSteps.data_source_name
).filter(
HCRawSteps.user_id == current_user.id
).distinct().all()
# Query distinct sources from Sleep
sleep_sources = db.query(
HCRawSleepSession.data_source,
HCRawSleepSession.data_source_name
).filter(
HCRawSleepSession.user_id == current_user.id
).distinct().all()
# Combine and Dedupe
sources_map = {}
for src, name in step_sources:
if src not in sources_map:
sources_map[src] = name or src
for src, name in sleep_sources:
if src not in sources_map:
sources_map[src] = name or src
return [
{"package": k, "name": v}
for k, v in sources_map.items()
]
@router.get("/daily", response_model=Dict[str, Any])
async def get_daily_analytics(
date_str: str, # YYYY-MM-DD
source: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get aggregated daily heath metrics from raw data for a specific date.
"""
try:
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
start_dt = datetime.combine(target_date, datetime.min.time())
end_dt = datetime.combine(target_date, datetime.max.time())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
stats = {}
# --- STEPS ---
steps_query = db.query(func.sum(HCRawSteps.count)).filter(
HCRawSteps.user_id == current_user.id,
HCRawSteps.start_time >= start_dt,
HCRawSteps.start_time <= end_dt
)
if source:
steps_query = steps_query.filter(HCRawSteps.data_source == source)
stats["steps"] = steps_query.scalar() or 0
# --- CALORIES ---
# Sum total calories
cals_query = db.query(func.sum(HCRawCalories.calories)).filter(
HCRawCalories.user_id == current_user.id,
HCRawCalories.start_time >= start_dt,
HCRawCalories.start_time <= end_dt
)
if source:
cals_query = cals_query.filter(HCRawCalories.data_source == source)
stats["calories"] = round(cals_query.scalar() or 0, 1)
# --- DISTANCE ---
dist_query = db.query(func.sum(HCRawDistance.distance_meters)).filter(
HCRawDistance.user_id == current_user.id,
HCRawDistance.start_time >= start_dt,
HCRawDistance.start_time <= end_dt
)
if source:
dist_query = dist_query.filter(HCRawDistance.data_source == source)
stats["distance_meters"] = round(dist_query.scalar() or 0, 1)
# --- SLEEP (Previous Night) ---
# Usually sleep for "today" means sleep ending today (morning).
# Logic: Sleep sessions ending between today 00:00 and today 23:59 (or noon to noon?)
# Health Connect usually assigns sleep to the day it ends.
# We sum duration of sessions overlap. Simpler: Sum duration of sessions ending on this date.
sleep_query = db.query(HCRawSleepSession).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
)
if source:
sleep_query = sleep_query.filter(HCRawSleepSession.data_source == source)
sessions = sleep_query.all()
total_sleep_seconds = 0
for s in sessions:
duration = (s.end_time - s.start_time).total_seconds()
total_sleep_seconds += duration
session_total_minutes = int(total_sleep_seconds / 60)
# Track bedtime/wake (earliest start, latest end)
bedtime = min([s.start_time for s in sessions]) if sessions else None
wake_time = max([s.end_time for s in sessions]) if sessions else None
stats["bedtime"] = bedtime.isoformat() if bedtime else None
stats["wake_time"] = wake_time.isoformat() if wake_time else None
# --- HEART RATE (Resting) ---
rhr_query = db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= start_dt,
HCRawRestingHeartRate.time <= end_dt
)
if source:
rhr_query = rhr_query.filter(HCRawRestingHeartRate.data_source == source)
stats["resting_hr"] = int(rhr_query.scalar() or 0)
# --- WEIGHT (Latest) ---
weight_query = db.query(HCRawWeight.weight_kg).filter(
HCRawWeight.user_id == current_user.id,
HCRawWeight.time <= end_dt
).order_by(HCRawWeight.time.desc())
if source:
weight_query = weight_query.filter(HCRawWeight.data_source == source)
latest_weight = weight_query.first()
stats["weight_kg"] = round(latest_weight[0], 1) if latest_weight else None
# --- SLEEP STAGES ---
# Sum duration per stage for sessions ending today
stages_query = db.query(
HCRawSleepStage.stage_name,
func.sum(func.extract('epoch', HCRawSleepStage.end_time) - func.extract('epoch', HCRawSleepStage.start_time))
).join(HCRawSleepSession).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
)
if source:
stages_query = stages_query.filter(HCRawSleepSession.data_source == source)
stages_result = stages_query.group_by(HCRawSleepStage.stage_name).all()
stats["deep_sleep_minutes"] = 0
stats["light_sleep_minutes"] = 0
stats["rem_sleep_minutes"] = 0
stats["awake_duration_minutes"] = 0
for stage_name, duration_seconds in stages_result:
if not duration_seconds:
continue
minutes = int(duration_seconds / 60)
# Normalize stage names
name = stage_name.lower() if stage_name else "unknown"
if "deep" in name:
stats["deep_sleep_minutes"] += minutes
elif "light" in name:
stats["light_sleep_minutes"] += minutes
elif "rem" in name:
stats["rem_sleep_minutes"] += minutes
elif "awake" in name:
stats["awake_duration_minutes"] += minutes
# --- SLEEP HEART RATE ---
if bedtime and wake_time:
sleep_hr_query = db.query(func.avg(HCRawHeartRateSample.bpm)).join(HCRawHeartRate).filter(
HCRawHeartRate.user_id == current_user.id,
HCRawHeartRateSample.time >= bedtime,
HCRawHeartRateSample.time <= wake_time
)
if source:
sleep_hr_query = sleep_hr_query.filter(HCRawHeartRate.data_source == source)
stats["sleep_avg_hr"] = int(sleep_hr_query.scalar() or 0)
else:
stats["sleep_avg_hr"] = None
# Final Sleep Calculation: Total Sleep = Deep + Light + REM
# This excludes Awake time which is often included in session duration
actual_sleep = stats["deep_sleep_minutes"] + stats["light_sleep_minutes"] + stats["rem_sleep_minutes"]
if actual_sleep > 0:
stats["sleep_minutes"] = actual_sleep
else:
# Fallback to session duration (minus awake if reported as a stage)
stats["sleep_minutes"] = max(0, session_total_minutes - stats["awake_duration_minutes"])
# --- HEART RATE DETAILED (Min/Max/Avg) ---
# Query samples directly for accuracy
hr_stats_query = db.query(
func.min(HCRawHeartRateSample.bpm),
func.max(HCRawHeartRateSample.bpm),
func.avg(HCRawHeartRateSample.bpm)
).join(HCRawHeartRate).filter(
HCRawHeartRate.user_id == current_user.id,
HCRawHeartRateSample.time >= start_dt,
HCRawHeartRateSample.time <= end_dt
)
if source:
hr_stats_query = hr_stats_query.filter(HCRawHeartRate.data_source == source)
min_hr, max_hr, avg_hr = hr_stats_query.first()
stats["min_heart_rate"] = min_hr
stats["max_heart_rate"] = max_hr
stats["avg_heart_rate"] = int(avg_hr) if avg_hr else None
# --- HRV ---
hrv_query = db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= start_dt,
HCRawHeartRateVariabilityRmssd.time <= end_dt
)
if source:
hrv_query = hrv_query.filter(HCRawHeartRateVariabilityRmssd.data_source == source)
avg_hrv = hrv_query.scalar()
stats["hrv"] = int(avg_hrv) if avg_hrv else None
# --- USER CONTEXT ---
context = db.query(DailyContext).filter(
DailyContext.user_id == current_user.id,
DailyContext.date == target_date
).first()
stats["user_context"] = {
"mood_score": context.mood_score if context else None,
"tags": context.tags.split(",") if context and context.tags else [],
"notes": context.notes if context else None
}
# --- READINESS CALCULATION ---
# 1. Fetch 30-day baselines for HRV and RHR
baseline_start = start_dt - timedelta(days=30)
baseline_hrv = float(db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= baseline_start,
HCRawHeartRateVariabilityRmssd.time < start_dt
).scalar() or 40.0) # Default fallback
baseline_rhr = float(db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= baseline_start,
HCRawRestingHeartRate.time < start_dt
).scalar() or 60.0) # Default fallback
# 2. Mental Score (Nervous System)
# HRV: Higher is better. RHR: Lower is better.
current_hrv = float(stats["hrv"] or baseline_hrv)
current_rhr = float(stats["resting_hr"] or baseline_rhr)
hrv_component = (current_hrv / baseline_hrv) * 100
rhr_component = (baseline_rhr / current_rhr) * 100
mental_score = min(100, max(0, (hrv_component + rhr_component) / 2))
# 3. Physical Score (Sleep + Activity)
# Sleep goal: 8h (480 min).
sleep_score = min(100, (stats["sleep_minutes"] / 480) * 100)
# Simple physical placeholder: just sleep for now as we don't have "yesterday's strain" handy easily without another query
# TODO: Add previous day strain integration
physical_score = sleep_score
overall_score = int((mental_score * 0.5) + (physical_score * 0.5))
# Recommendation Logic
recommendation = ""
if overall_score >= 80:
recommendation = "All systems go! Your body and mind are primed for high intensity."
elif overall_score < 40:
recommendation = "Rest day advised. Focus on recovery and sleep."
else:
# Mixed signals
if mental_score < physical_score - 10:
recommendation = "Body is ready, but nervous system is tired. Avoid HIIT, try steady cardio or yoga."
elif physical_score < mental_score - 10:
recommendation = "Mind is fresh, but body needs rest. Light technical work or mobility."
else:
recommendation = "Moderate intensity recommended. Listen to your body."
stats["readiness"] = {
"score": overall_score,
"mental_score": int(mental_score),
"physical_score": int(physical_score),
"hrv_baseline": int(baseline_hrv),
"rhr_baseline": int(baseline_rhr),
"recommendation": recommendation
}
return stats
@router.get("/range", response_model=Dict[str, Any])
async def get_range_analytics(
start_date: str,
end_date: str,
source: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get daily breakdown for a date range (e.g. for charts).
"""
try:
s_date = datetime.strptime(start_date, "%Y-%m-%d").date()
e_date = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
# Generate list of dates
delta = e_date - s_date
days = []
for i in range(delta.days + 1):
day = s_date + timedelta(days=i)
days.append(day)
results = []
# Optimized: We could do a big Group By query, but simplistic approach first loop is safer for logic correctness
# For performance on 30 days, we should use SQL Group By.
# STEPS Group By
start_dt = datetime.combine(s_date, datetime.min.time())
end_dt = datetime.combine(e_date, datetime.max.time())
# helper for range query
def get_daily_sum(model, value_col):
qs = db.query(
func.date(model.start_time).label("day"),
func.sum(value_col)
).filter(
model.user_id == current_user.id,
model.start_time >= start_dt,
model.start_time <= end_dt
)
if source:
qs = qs.filter(model.data_source == source)
return dict(qs.group_by(func.date(model.start_time)).all())
steps_data = get_daily_sum(HCRawSteps, HCRawSteps.count)
cals_data = get_daily_sum(HCRawCalories, HCRawCalories.calories)
dist_data = get_daily_sum(HCRawDistance, HCRawDistance.distance_meters)
# Resting HR (Average per day)
rhr_qs = db.query(
func.date(HCRawRestingHeartRate.time).label("day"),
func.avg(HCRawRestingHeartRate.bpm)
).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= start_dt,
HCRawRestingHeartRate.time <= end_dt
)
if source:
rhr_qs = rhr_qs.filter(HCRawRestingHeartRate.data_source == source)
rhr_data = dict(rhr_qs.group_by(func.date(HCRawRestingHeartRate.time)).all())
# HRV (Average per day)
hrv_qs = db.query(
func.date(HCRawHeartRateVariabilityRmssd.time).label("day"),
func.avg(HCRawHeartRateVariabilityRmssd.rmssd)
).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= start_dt,
HCRawHeartRateVariabilityRmssd.time <= end_dt
)
if source:
hrv_qs = hrv_qs.filter(HCRawHeartRateVariabilityRmssd.data_source == source)
hrv_data = dict(hrv_qs.group_by(func.date(HCRawHeartRateVariabilityRmssd.time)).all())
# Sleep is trickier. We want Deep + Light + REM, excluding Awake.
# 1. Query stages excluding awake
sleep_stages_qs = db.query(
func.date(HCRawSleepSession.end_time).label("day"),
func.sum(func.extract('epoch', HCRawSleepStage.end_time) - func.extract('epoch', HCRawSleepStage.start_time))
).join(HCRawSleepStage).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt,
func.lower(HCRawSleepStage.stage_name).notlike('%awake%')
).group_by(func.date(HCRawSleepSession.end_time))
if source:
sleep_stages_qs = sleep_stages_qs.filter(HCRawSleepSession.data_source == source)
sleep_stages_map = {str(d): s/60 for d, s in sleep_stages_qs.all()}
# 2. Query session totals as fallback
sleep_sess_qs = db.query(
func.date(HCRawSleepSession.end_time).label("day"),
func.sum(func.extract('epoch', HCRawSleepSession.end_time) - func.extract('epoch', HCRawSleepSession.start_time))
).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
).group_by(func.date(HCRawSleepSession.end_time))
if source:
sleep_sess_qs = sleep_sess_qs.filter(HCRawSleepSession.data_source == source)
sleep_sess_map = {str(d): s/60 for d, s in sleep_sess_qs.all()}
# Merge: Prioritize stages
sleep_data = {}
for d_str in set(list(sleep_stages_map.keys()) + list(sleep_sess_map.keys())):
sleep_data[d_str] = sleep_stages_map.get(d_str) or sleep_sess_map.get(d_str) or 0
# 3. Query bedtime/wake per day
sleep_times_qs = db.query(
func.date(HCRawSleepSession.end_time).label("day"),
func.min(HCRawSleepSession.start_time),
func.max(HCRawSleepSession.end_time)
).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
).group_by(func.date(HCRawSleepSession.end_time))
if source:
sleep_times_qs = sleep_times_qs.filter(HCRawSleepSession.data_source == source)
sleep_times_map = {str(d): {"bedtime": b.isoformat(), "wake_time": w.isoformat()} for d, b, w in sleep_times_qs.all()}
# Construct response
daily_data = []
for day in days:
day_str = day.strftime("%Y-%m-%d")
# Helper to safely get value from dict with string or date keys
def get_val(data_dict):
val = data_dict.get(day_str, 0)
if not val and data_dict.get(day):
val = data_dict.get(day)
return val or 0
times = sleep_times_map.get(day_str, {})
daily_data.append({
"date": day_str,
"steps": int(get_val(steps_data)),
"calories": round(get_val(cals_data), 1),
"distance_meters": round(get_val(dist_data), 1),
"resting_hr": int(get_val(rhr_data)),
"hrv": int(get_val(hrv_data)),
"sleep_minutes": int(sleep_data.get(day_str, 0)),
"bedtime": times.get("bedtime"),
"wake_time": times.get("wake_time"),
"mood_score": 0, # Placeholder
"strain": int(get_val(cals_data) / 20), # Rough strain proxy (2000 cal = 100 strain)
"recovery": 0 # Placeholder
})
# Enrich with Context and simple readiness (optimized)
# Fetch all context for range
context_map = {}
contexts = db.query(DailyContext).filter(
DailyContext.user_id == current_user.id,
DailyContext.date >= s_date,
DailyContext.date <= e_date
).all()
for c in contexts:
context_map[str(c.date)] = {"mood": c.mood_score, "tags": c.tags}
# Calculate baselines once (using end_date for simplicity)
baseline_start = e_date - timedelta(days=30)
base_hrv = float(db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= datetime.combine(baseline_start, datetime.min.time()),
HCRawHeartRateVariabilityRmssd.time <= datetime.combine(e_date, datetime.max.time())
).scalar() or 40.0)
base_rhr = float(db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= datetime.combine(baseline_start, datetime.min.time()),
HCRawRestingHeartRate.time <= datetime.combine(e_date, datetime.max.time())
).scalar() or 60.0)
for d in daily_data:
d_date = d["date"]
# Context
if d_date in context_map:
d["mood_score"] = context_map[d_date]["mood"]
d["tags"] = context_map[d_date]["tags"]
# Readiness Calculation
d_hrv = float(d["hrv"] or base_hrv)
d_rhr = float(d["resting_hr"] or base_rhr)
d_sleep = d["sleep_minutes"]
mental = ((d_hrv / base_hrv) * 0.5 + (base_rhr / d_rhr) * 0.5) * 100
physical = min(100, (d_sleep / 480) * 100)
d["readiness"] = int((mental + physical) / 2)
d["recovery_score"] = int(mental) # Use mental as "Recovery" for the chart green bar? Or overall readiness.
return {
"daily": daily_data,
"baselines": {
"hrv": int(base_hrv),
"rhr": int(base_rhr)
}
}
class ContextUpdate(BaseModel):
date: str
mood: int
tags: Optional[str] = None
notes: Optional[str] = None
@router.post("/context", status_code=status.HTTP_201_CREATED)
async def update_daily_context(
update: ContextUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Update subjective user context (mood, tags) for a specific date.
"""
try:
target_date = datetime.strptime(update.date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
context = db.query(DailyContext).filter(
DailyContext.user_id == current_user.id,
DailyContext.date == target_date
).first()
if not context:
context = DailyContext(
user_id=current_user.id,
date=target_date,
mood_score=update.mood,
tags=update.tags,
notes=update.notes
)
db.add(context)
else:
context.mood_score = update.mood
context.tags = update.tags
context.notes = update.notes
db.commit()
db.refresh(context)
return {"status": "success", "id": context.id}