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}