599 lines
22 KiB
Python
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}
|