Add initial project

This commit is contained in:
2026-07-30 23:25:20 -04:00
commit cff6aa8289
164 changed files with 26672 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
Haut du corps (Bras, Épaules, Pectoraux, Dos)
faire l'image pour "Dips" en conservant la fille et l'environnement
faire l'image pour "Diamond Push-ups" en conservant la fille et l'environnement
faire l'image pour "Pike Push-ups" en conservant la fille et l'environnement
faire l'image pour "Plank to Push-up" en conservant la fille et l'environnement
faire l'image pour "Chin-ups" en conservant la fille et l'environnement
faire une image pour "Inverted Rows" en conservant la fille et l'environnement
faire une image pour "Superman" en conservant la fille et l'environnement
Bas du corps (Jambes, Fessiers)
faire une image pour "Lunges (Fentes)" en conservant la fille et l'environnement
faire une image pour "Glute Bridges" en conservant la fille et l'environnement
faire une image pour "Wall Sit" en conservant la fille et l'environnement
faire une image pour "Calf Raises" en conservant la fille et l'environnement
faire une image pour "Jump Squats" en conservant la fille et l'environnement
Abdominaux et Gainage
faire une image pour "Plank" en conservant la fille et l'environnement
faire une image pour "Russian Twists" en conservant la fille et l'environnement
faire une image pour "Leg Raises" en conservant la fille et l'environnement
faire une image pour "Mountain Climbers" en conservant la fille et l'environnement
basé sur une image créée par un AI, faire une image pour une exercise de "Bicycle Crunches" en conservant la fille et l'environnement
Cardio et Corps complet
faire une image pour "Burpees" en conservant la fille et l'environnement
faire une image pour "Jumping Jacks" en conservant la fille et l'environnement
+23
View File
@@ -0,0 +1,23 @@
"""Backend module for HabitForge."""
from backend.models import User, Challenge, Tracking, DailyHealthMetrics
from backend.schemas import (
UserCreate, UserResponse,
ChallengeBase, ChallengeCreate, ChallengeResponse,
TrackingBase, TrackingCreate, TrackingResponse,
DailyHealthMetricsBase, DailyHealthMetricsResponse,
Token
)
from backend import auth, database
__all__ = [
# Models
"User", "Challenge", "Tracking", "DailyHealthMetrics",
# Schemas
"UserCreate", "UserResponse",
"ChallengeBase", "ChallengeCreate", "ChallengeResponse",
"TrackingBase", "TrackingCreate", "TrackingResponse",
"DailyHealthMetricsBase", "DailyHealthMetricsResponse",
"Token",
# Modules
"auth", "database"
]
+1
View File
@@ -0,0 +1 @@
"""API module for HabitForge."""
+1
View File
@@ -0,0 +1 @@
"""API v1 module for HabitForge."""
+107
View File
@@ -0,0 +1,107 @@
"""Authentication endpoints for API v1."""
from fastapi import APIRouter, Depends, HTTPException, status, Request, Body
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from typing import List
from backend.models.user import User
from backend import auth
from backend.schemas.user import UserCreate, UserResponse
from backend.schemas.token import Token
from backend.database import get_db
from backend.core.config import settings
from slowapi import Limiter
from slowapi.util import get_remote_address
router = APIRouter(prefix="/api/auth", tags=["authentication"])
limiter = Limiter(key_func=get_remote_address)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
def register(request: Request, user: UserCreate, db: Session = Depends(get_db)):
"""Register a new user."""
db_user = db.query(User).filter(User.username == user.username).first()
if db_user:
raise HTTPException(status_code=400, detail="Username already registered")
hashed_password = auth.get_password_hash(user.password)
new_user = User(username=user.username, hashed_password=hashed_password)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.post("/token", response_model=Token)
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
def login_for_access_token(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""Login and get access token."""
user = db.query(User).filter(User.username == form_data.username).first()
if not user or not auth.verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
from datetime import timedelta
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = auth.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
refresh_token_expires = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
refresh_token = auth.create_refresh_token(
data={"sub": user.username}, expires_delta=refresh_token_expires
)
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}
@router.post("/refresh", response_model=Token)
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
def refresh_token(
request: Request,
refresh_token: str = Body(..., embed=True),
db: Session = Depends(get_db)
):
"""Refresh access token using refresh token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
from datetime import timedelta
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = auth.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
refresh_token_expires = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
new_refresh_token = auth.create_refresh_token(
data={"sub": user.username}, expires_delta=refresh_token_expires
)
return {"access_token": access_token, "refresh_token": new_refresh_token, "token_type": "bearer"}
@router.get("/me", response_model=UserResponse)
def read_users_me(current_user: User = Depends(auth.get_current_user)):
"""Get current user information."""
return current_user
+118
View File
@@ -0,0 +1,118 @@
"""Challenge endpoints for API v1."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional
import datetime
from backend.models.challenge import Challenge
from backend.models.tracking import Tracking
from backend.models.user import User
from backend import auth
from backend.schemas.challenge import ChallengeCreate, ChallengeResponse
from backend.schemas.tracking import TrackingCreate, TrackingResponse
from backend.database import get_db
router = APIRouter(prefix="/api/challenges", tags=["challenges"])
@router.post("", response_model=ChallengeResponse, status_code=status.HTTP_201_CREATED)
def create_challenge(
challenge: ChallengeCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Create a new challenge."""
# Calculate next display_order for THIS user
max_order = db.query(func.max(Challenge.display_order)).filter(
Challenge.user_id == current_user.id
).scalar()
new_order = (max_order if max_order is not None else -1) + 1
db_challenge = Challenge(**challenge.dict(), display_order=new_order, user_id=current_user.id)
db.add(db_challenge)
db.commit()
db.refresh(db_challenge)
return db_challenge
@router.get("", response_model=List[ChallengeResponse])
def read_challenges(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get all challenges for the current user."""
challenges = db.query(Challenge).filter(
Challenge.user_id == current_user.id
).order_by(Challenge.display_order.asc()).all()
return challenges
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_challenges(
ordered_ids: List[int],
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Reorder challenges."""
# Sanity check: ensure these challenges belong to user
for index, challenge_id in enumerate(ordered_ids):
db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).update({"display_order": index})
db.commit()
return None
@router.delete("/{challenge_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_challenge(
challenge_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Delete a challenge."""
db_challenge = db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).first()
if not db_challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
db.delete(db_challenge)
db.commit()
return None
@router.post("/{challenge_id}/tracking", response_model=TrackingResponse)
def log_tracking(
challenge_id: int,
tracking: TrackingCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Log tracking for a challenge."""
# Verify challenge belongs to user
challenge = db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).first()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
# Check if tracking exists for this date and challenge
db_tracking = db.query(Tracking).filter(
Tracking.challenge_id == challenge_id,
Tracking.date == tracking.date
).first()
if db_tracking:
db_tracking.reps = tracking.reps
db_tracking.completed = tracking.completed
db_tracking.notes = tracking.notes
else:
db_tracking = Tracking(**tracking.dict())
db.add(db_tracking)
db.commit()
db.refresh(db_tracking)
return db_tracking
+384
View File
@@ -0,0 +1,384 @@
"""Health check and metric endpoints for API v1."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
import os
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional
import datetime
from backend.database import get_db
from backend.models.daily_health_metrics import DailyHealthMetrics
from backend.models.user import User
from backend.models.workout_session import WorkoutSession
from backend.schemas.daily_health_metrics import DailyHealthMetricsResponse
from backend import auth
router = APIRouter(prefix="/api/health", tags=["health"])
@router.get("", status_code=status.HTTP_200_OK)
def health_check(db: Session = Depends(get_db)):
"""Check if the application and database are healthy."""
try:
# Check database connection
db.execute(text("SELECT 1"))
return {
"status": "healthy",
"database": "connected"
}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Unhealthy: {str(e)}")
# Valid endpoints start here
@router.get("/stats", response_model=List[DailyHealthMetricsResponse])
def get_health_stats(
period: str = "week",
ref_date: Optional[datetime.date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Retrieve health statistics for various periods."""
# Imports for Raw Data
from backend.models.health_connect_raw import HCRawWeight
from sqlalchemy import func
# Helper to fetch raw weights
def get_raw_weights(start, end):
return db.query(HCRawWeight).filter(
HCRawWeight.user_id == current_user.id,
HCRawWeight.time >= start,
HCRawWeight.time <= end
).order_by(HCRawWeight.time.asc()).all()
if ref_date is None:
ref_date = datetime.date.today()
start_date = None
end_date = datetime.datetime.combine(ref_date, datetime.time.max) # End of ref day
if period == "day":
start_date = datetime.datetime.combine(ref_date, datetime.time.min)
elif period == "week":
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=6), datetime.time.min)
elif period == "month":
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=29), datetime.time.min)
elif period == "year":
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=365), datetime.time.min)
# Base Metrics Query
metrics_query = db.query(DailyHealthMetrics).filter(
DailyHealthMetrics.user_id == current_user.id
)
if period == "day":
metrics_query = metrics_query.filter(DailyHealthMetrics.date == ref_date)
else:
metrics_query = metrics_query.filter(DailyHealthMetrics.date.between(start_date.date(), ref_date))
metrics_data = metrics_query.order_by(DailyHealthMetrics.date.asc()).all()
# If Year, handle aggregation specially
if period == "year":
# Raw weights for the year
raw_weights = get_raw_weights(start_date, end_date)
# Month-based aggregation for metrics
aggregated = []
import itertools
from statistics import mean
# Merge raw weights into metrics_data conceptually?
# Actually easier to aggregate metrics_data first, then overlay weight.
metrics_by_month = {}
for m in metrics_data:
key = (m.date.year, m.date.month)
if key not in metrics_by_month: metrics_by_month[key] = []
metrics_by_month[key].append(m)
weights_by_month = {}
for w in raw_weights:
key = (w.time.year, w.time.month)
if key not in weights_by_month: weights_by_month[key] = []
weights_by_month[key].append(w.weight_kg)
# distinct months from both sources
all_months = set(metrics_by_month.keys()) | set(weights_by_month.keys())
for key in sorted(all_months):
year, month = key
g_metrics = metrics_by_month.get(key, [])
g_weights = weights_by_month.get(key, [])
# Use first metric date or construct one
if g_metrics:
base_date = g_metrics[0].date.replace(day=1)
else:
base_date = datetime.date(year, month, 1)
agg = {
"id": int(f"{year}{month:02d}"), # Fake ID
"user_id": current_user.id,
"date": base_date,
# Defaults if no metrics
"step_count": (sum(x.step_count for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
"step_count_total": sum(x.step_count for x in g_metrics) if g_metrics else 0,
"calories_burned": (sum(x.calories_burned for x in g_metrics) / len(g_metrics)) if g_metrics else 0,
"distance_meters": (sum(x.distance_meters for x in g_metrics) / len(g_metrics)) if g_metrics else 0,
"sleep_duration_minutes": (sum(x.sleep_duration_minutes for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
"avg_heart_rate": (sum(x.avg_heart_rate or 0 for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
# ... skip other fields for brevity unless critical ...
"min_heart_rate": min((x.min_heart_rate for x in g_metrics if x.min_heart_rate), default=0) if g_metrics else 0,
"max_heart_rate": max((x.max_heart_rate for x in g_metrics if x.max_heart_rate), default=0) if g_metrics else 0,
# WEIGHT from RAW (schema uses 'weight')
"weight": mean(g_weights) if g_weights else 0
}
# If weight missing in raw but present in aggregated (unlikely but possible)
if agg["weight"] == 0 and g_metrics:
# Fallback to avg of metrics weight if available
w_vals = [x.weight for x in g_metrics if x.weight]
if w_vals: agg["weight"] = mean(w_vals)
aggregated.append(agg)
return aggregated
# For Day/Week/Month
# Fetch Raw Weights
raw_weights = get_raw_weights(start_date, end_date)
# Map raw weights to date (take latest per date)
weights_by_date = {}
for w in raw_weights:
d = w.time.date()
weights_by_date[d] = w.weight_kg # overwrites with latest
# Merge into metrics_data
# 1. Update existing
processed_dates = set()
final_list = []
for m in metrics_data:
processed_dates.add(m.date)
# Overlay raw weight if available
if m.date in weights_by_date:
m.weight = weights_by_date[m.date] # set attribute dynamically
final_list.append(m)
# 2. Add missing dates (if we have weight but no daily metric)
for d, w in weights_by_date.items():
if d not in processed_dates:
# Create dummy object
dummy = DailyHealthMetricsResponse(
id=0,
user_id=current_user.id,
date=d,
step_count=0,
calories_burned=0,
distance_meters=0,
sleep_duration_minutes=0,
weight=w, # Our raw weight
# defaults
deep_sleep_minutes=0, light_sleep_minutes=0, rem_sleep_minutes=0, awake_duration_minutes=0,
pai_score=0, avg_heart_rate=0, min_heart_rate=0, max_heart_rate=0, avg_spo2=0, resting_heart_rate=0, hrv=0
)
final_list.append(dummy)
# Sort
final_list.sort(key=lambda x: x.date)
return final_list
@router.get("/workouts")
def get_workouts(
limit: int = 100,
offset: int = 0,
activity_type: Optional[str] = None,
start_date: Optional[datetime.date] = None,
end_date: Optional[datetime.date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Retrieve workout sessions for the current user (merges manual and raw data)."""
from backend.models.workout_session import WorkoutSession
from backend.models.health_connect_raw import HCRawExerciseSession
start_dt = datetime.datetime.combine(start_date, datetime.time.min) if start_date else None
end_dt = datetime.datetime.combine(end_date, datetime.time.max) if end_date else None
# 1. Fetch from WorkoutSession
query_ws = db.query(WorkoutSession).filter(WorkoutSession.user_id == current_user.id)
if activity_type:
query_ws = query_ws.filter(WorkoutSession.activity_type == activity_type)
if start_dt:
query_ws = query_ws.filter(WorkoutSession.start_time >= start_dt)
if end_dt:
query_ws = query_ws.filter(WorkoutSession.start_time <= end_dt)
workouts_ws = query_ws.order_by(WorkoutSession.start_time.desc()).offset(offset).limit(limit).all()
# 2. Fetch from HCRawExerciseSession
query_raw = db.query(HCRawExerciseSession).filter(HCRawExerciseSession.user_id == current_user.id)
if activity_type:
query_raw = query_raw.filter(HCRawExerciseSession.exercise_type_name == activity_type)
if start_dt:
query_raw = query_raw.filter(HCRawExerciseSession.start_time >= start_dt)
if end_dt:
query_raw = query_raw.filter(HCRawExerciseSession.start_time <= end_dt)
workouts_raw = query_raw.order_by(HCRawExerciseSession.start_time.desc()).offset(offset).limit(limit).all()
# Combine and Map
combined = []
for w in workouts_ws:
combined.append({
"id": f"ws_{w.id}",
"user_id": w.user_id,
"start_time": w.start_time.isoformat() if w.start_time else None,
"end_time": w.end_time.isoformat() if w.end_time else None,
"activity_type": w.activity_type,
"activity_type_id": w.activity_type_id,
"duration_seconds": w.duration_seconds or 0,
"distance_meters": w.distance_meters or 0.0,
"calories": w.calories or 0.0,
"avg_pace": w.avg_pace,
"max_pace": w.max_pace,
"min_pace": w.min_pace,
"avg_hr": w.avg_hr,
"max_hr": w.max_hr,
"min_hr": w.min_hr,
"notes": w.notes,
"source": w.data_source
})
for w in workouts_raw:
duration = 0
if w.start_time and w.end_time:
duration = int((w.end_time - w.start_time).total_seconds())
combined.append({
"id": f"raw_{w.id}",
"user_id": w.user_id,
"start_time": w.start_time.isoformat() if w.start_time else None,
"end_time": w.end_time.isoformat() if w.end_time else None,
"activity_type": w.exercise_type_name or f"Activity {w.exercise_type}",
"activity_type_id": w.exercise_type,
"duration_seconds": duration,
"distance_meters": w.distance_meters or 0.0,
"calories": w.calories or 0.0,
"avg_pace": None,
"max_pace": None,
"min_pace": None,
"avg_hr": w.avg_heart_rate,
"max_hr": w.max_heart_rate,
"min_hr": w.min_heart_rate,
"notes": w.notes,
"source": "health_connect"
})
# Sort combined list by start_time DESC
combined.sort(key=lambda x: x["start_time"] if x["start_time"] else "", reverse=True)
return combined[:limit]
@router.get("/workouts/summary")
def get_workout_summary(
start_date: Optional[datetime.date] = None,
end_date: Optional[datetime.date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get summary statistics for workouts (combines manual and raw data)."""
from backend.models.workout_session import WorkoutSession
from backend.models.health_connect_raw import HCRawExerciseSession
from sqlalchemy import func
start_dt = datetime.datetime.combine(start_date, datetime.time.min) if start_date else None
end_dt = datetime.datetime.combine(end_date, datetime.time.max) if end_date else None
# 1. Stats from WorkoutSession
query_ws = db.query(
WorkoutSession.activity_type,
func.count(WorkoutSession.id).label('count'),
func.sum(WorkoutSession.duration_seconds).label('total_duration'),
func.sum(WorkoutSession.calories).label('total_calories'),
func.sum(WorkoutSession.distance_meters).label('total_distance')
).filter(WorkoutSession.user_id == current_user.id)
if start_dt:
query_ws = query_ws.filter(WorkoutSession.start_time >= start_dt)
if end_dt:
query_ws = query_ws.filter(WorkoutSession.start_time <= end_dt)
by_type_ws = query_ws.group_by(WorkoutSession.activity_type).all()
# 2. Stats from HCRawExerciseSession
# We fetch the sessions and aggregate in Python to avoid SQLite date extraction issues
query_raw = db.query(HCRawExerciseSession).filter(HCRawExerciseSession.user_id == current_user.id)
if start_dt:
query_raw = query_raw.filter(HCRawExerciseSession.start_time >= start_dt)
if end_dt:
query_raw = query_raw.filter(HCRawExerciseSession.start_time <= end_dt)
sessions_raw = query_raw.all()
# Merge results
summary_map = {}
# Process manual sessions
for row in by_type_ws:
name = row[0] or "Unknown"
summary_map[name] = {
"count": row[1],
"duration": row[2] or 0,
"calories": row[3] or 0,
"distance": row[4] or 0
}
# Process raw sessions
for w in sessions_raw:
name = w.exercise_type_name or f"Activity {w.exercise_type}"
duration = 0
if w.start_time and w.end_time:
duration = (w.end_time - w.start_time).total_seconds()
if name in summary_map:
summary_map[name]["count"] += 1
summary_map[name]["duration"] += duration
summary_map[name]["calories"] += (w.calories or 0)
summary_map[name]["distance"] += (w.distance_meters or 0)
else:
summary_map[name] = {
"count": 1,
"duration": duration,
"calories": w.calories or 0,
"distance": w.distance_meters or 0
}
total_workouts = sum(s["count"] for s in summary_map.values())
return {
"total_workouts": total_workouts,
"by_activity_type": [
{
"activity_type": name,
"count": stats["count"],
"total_duration_seconds": int(stats["duration"]),
"total_calories": round(stats["calories"], 1),
"total_distance_meters": round(stats["distance"], 1)
}
for name, stats in summary_map.items()
]
}
+598
View File
@@ -0,0 +1,598 @@
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}
+363
View File
@@ -0,0 +1,363 @@
"""
Health Connect API - Endpoints for receiving data from Android companion app.
These endpoints allow the Android companion app to push health data
from Health Connect to the HabitForge backend.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Header
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime, date
import hashlib
import hmac
import logging
logger = logging.getLogger(__name__)
from backend.database import get_db
from backend.models.user import User
from backend.models.health_connect import (
HealthConnectSyncLog,
HealthConnectDailyMetrics,
HealthConnectExerciseSession,
HEALTH_CONNECT_EXERCISE_TYPES,
)
from backend.models.daily_health_metrics import DailyHealthMetrics
from backend.models.workout_session import WorkoutSession
from backend import auth
from backend.core.config import settings
router = APIRouter(prefix="/api/health-connect", tags=["health-connect"])
# --- Request Models ---
class DailyMetricsPayload(BaseModel):
"""Payload for daily health metrics from Health Connect."""
date: date
step_count: int = 0
distance_meters: float = 0.0
calories_burned: float = 0.0
total_calories: float = 0.0
sleep_duration_minutes: int = 0
deep_sleep_minutes: int = 0
light_sleep_minutes: int = 0
rem_sleep_minutes: int = 0
awake_duration_minutes: int = 0
avg_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
resting_heart_rate: Optional[int] = None
avg_spo2: Optional[float] = None
min_spo2: Optional[float] = None
max_spo2: Optional[float] = None
weight: Optional[float] = None
# Data source tracking
data_source: Optional[str] = None # Package name (e.g., "com.huami.watch.hmwatchmanager")
data_source_name: Optional[str] = None # Friendly name (e.g., "Zepp")
class ExerciseSessionPayload(BaseModel):
"""Payload for exercise session from Health Connect."""
start_time: datetime
end_time: datetime
exercise_type: int # Health Connect ExerciseType enum value
exercise_type_name: Optional[str] = None
duration_seconds: int = 0
distance_meters: Optional[float] = None
calories_burned: Optional[float] = None
avg_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
steps: Optional[int] = None
elevation_gained: Optional[float] = None
notes: Optional[str] = None
class HealthConnectSyncRequest(BaseModel):
"""Full sync request from Android companion app."""
device_id: str
sync_timestamp: str = "" # Added for Android compatibility
daily_metrics: List[DailyMetricsPayload] = []
exercise_sessions: List[ExerciseSessionPayload] = []
class SyncResponse(BaseModel):
"""Response for sync operation."""
status: str
sync_id: int
metrics_synced: int # Renamed from metrics_processed to match Android
exercises_synced: int # Renamed from sessions_processed to match Android
message: str
# --- Endpoints ---
@router.post("/sync", response_model=SyncResponse, status_code=status.HTTP_200_OK)
async def receive_health_connect_data(
request: HealthConnectSyncRequest,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Receive health data pushed from Android companion app.
This endpoint accepts data from Health Connect and stores it
for later retrieval by the HealthConnectProvider.
"""
logger.info(f"=== Health Connect Sync Request from user {current_user.username} ===")
logger.info(f"Device ID: {request.device_id}")
logger.info(f"Timestamp: {request.sync_timestamp}")
logger.info(f"Daily metrics count: {len(request.daily_metrics)}")
logger.info(f"Exercise sessions count: {len(request.exercise_sessions)}")
# Log sample data for debugging
if request.daily_metrics:
sample = request.daily_metrics[0]
logger.info(f"Sample metric - Date: {sample.date}, Steps: {sample.step_count}, Calories: {sample.calories_burned}")
if request.exercise_sessions:
sample = request.exercise_sessions[0]
logger.info(f"Sample exercise - Type: {sample.exercise_type_name}, Duration: {sample.duration_seconds}s")
# Create sync log entry
sync_log = HealthConnectSyncLog(
user_id=current_user.id,
device_id=request.device_id,
records_pushed=len(request.daily_metrics) + len(request.exercise_sessions),
status="processing"
)
db.add(sync_log)
db.flush() # Get the sync_log.id
metrics_synced = 0
exercises_synced = 0
try:
# Process daily metrics
for metrics in request.daily_metrics:
# Upsert: update if exists, insert if not
existing = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == current_user.id,
HealthConnectDailyMetrics.date == metrics.date
).first()
if existing:
# Update existing record
for field, value in metrics.model_dump().items():
if value is not None:
setattr(existing, field, value)
existing.last_synced = datetime.utcnow()
existing.sync_id = sync_log.id
logger.info(f"Updated existing metric for date {metrics.date}")
else:
# Create new record
new_metrics = HealthConnectDailyMetrics(
user_id=current_user.id,
sync_id=sync_log.id,
**metrics.model_dump()
)
db.add(new_metrics)
logger.info(f"Created new metric for date {metrics.date}")
metrics_synced += 1
# --- AUTO-SYNC to Main DailyHealthMetrics Table ---
try:
main_existing = db.query(DailyHealthMetrics).filter(
DailyHealthMetrics.user_id == current_user.id,
DailyHealthMetrics.date == metrics.date
).first()
# Filter fields that exist in DailyHealthMetrics
metric_data = metrics.model_dump()
# Manual Mapping / Cleanup
# DailyMetricsPayload has snake_case keys which match DailyHealthMetrics mostly
# Exceptions:
# - DailyHealthMetrics has no 'data_source_name'
# - DailyHealthMetrics has 'data_source' (string)
update_data = {}
for key, value in metric_data.items():
if value is not None and hasattr(DailyHealthMetrics, key):
update_data[key] = value
# Explicitly set data source
update_data["data_source"] = "health_connect"
if main_existing:
# Update if value is not None (and maybe preferential logic?)
# For now, we trust the push as latest source of truth
for key, value in update_data.items():
setattr(main_existing, key, value)
logger.info(f"Auto-synced main health metric for {metrics.date}")
else:
new_main = DailyHealthMetrics(
user_id=current_user.id,
**update_data
)
db.add(new_main)
logger.info(f"Created main health metric for {metrics.date}")
except Exception as e:
logger.error(f"Failed to auto-sync daily metric to main table: {e}")
# Don't fail the request, just log
# Process exercise sessions
for session in request.exercise_sessions:
# Dedup by start_time
existing = db.query(HealthConnectExerciseSession).filter(
HealthConnectExerciseSession.user_id == current_user.id,
HealthConnectExerciseSession.start_time == session.start_time
).first()
if not existing:
# Get exercise type name if not provided
exercise_name = session.exercise_type_name or HEALTH_CONNECT_EXERCISE_TYPES.get(
session.exercise_type, f"Exercise {session.exercise_type}"
)
new_session = HealthConnectExerciseSession(
user_id=current_user.id,
sync_id=sync_log.id,
exercise_type_name=exercise_name,
**session.model_dump(exclude={"exercise_type_name"})
)
db.add(new_session)
exercises_synced += 1
logger.info(f"Created new exercise session: {exercise_name}")
# --- AUTO-SYNC to Main WorkoutSession Table ---
try:
# Check existence in main table (simple check by start time)
main_workout_exists = db.query(WorkoutSession).filter(
WorkoutSession.user_id == current_user.id,
WorkoutSession.start_time == session.start_time
).first()
if not main_workout_exists:
# key mapping
sess_data = session.model_dump(exclude={"exercise_type_name"})
# WorkoutSession fields:
# start_time, end_time, activity_type (str), activity_type_id (int),
# duration_seconds, distance_meters, calories (not calories_burned),
# avg_hr, max_hr, min_hr, notes
new_workout = WorkoutSession(
user_id=current_user.id,
start_time=session.start_time,
end_time=session.end_time,
activity_type=exercise_name,
activity_type_id=session.exercise_type,
duration_seconds=session.duration_seconds,
distance_meters=session.distance_meters or 0,
calories=session.calories_burned or 0,
avg_hr=session.avg_heart_rate,
max_hr=session.max_heart_rate,
min_hr=session.min_heart_rate,
notes=session.notes,
data_source="health_connect"
)
db.add(new_workout)
logger.info(f"Auto-synced workout session: {exercise_name}")
except Exception as e:
logger.error(f"Failed to auto-sync workout to main table: {e}")
else:
logger.info(f"Skipped duplicate exercise session at {session.start_time}")
# Update sync log
sync_log.status = "success"
sync_log.records_pushed = metrics_synced + exercises_synced
db.commit()
logger.info(f"=== Sync Complete: {metrics_synced} metrics, {exercises_synced} exercises ===")
return SyncResponse(
status="success",
sync_id=sync_log.id,
metrics_synced=metrics_synced,
exercises_synced=exercises_synced,
message=f"Successfully synced {metrics_synced} daily records and {exercises_synced} exercise sessions"
)
except Exception as e:
logger.error(f"Sync failed: {str(e)}", exc_info=True)
sync_log.status = "error"
sync_log.error_message = str(e)
db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Sync failed: {str(e)}"
)
@router.get("/status", status_code=status.HTTP_200_OK)
async def get_sync_status(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get the status of Health Connect sync for current user."""
# Get latest sync
latest_sync = db.query(HealthConnectSyncLog).filter(
HealthConnectSyncLog.user_id == current_user.id
).order_by(HealthConnectSyncLog.sync_timestamp.desc()).first()
# Count records
metrics_count = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == current_user.id
).count()
sessions_count = db.query(HealthConnectExerciseSession).filter(
HealthConnectExerciseSession.user_id == current_user.id
).count()
return {
"has_data": metrics_count > 0 or sessions_count > 0,
"metrics_count": metrics_count,
"sessions_count": sessions_count,
"last_sync": latest_sync.sync_timestamp if latest_sync else None,
"last_sync_status": latest_sync.status if latest_sync else None,
"last_device_id": latest_sync.device_id if latest_sync else None
}
@router.get("/metrics", status_code=status.HTTP_200_OK)
async def get_health_connect_metrics(
days: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get Health Connect daily metrics for current user."""
from datetime import timedelta
start_date = date.today() - timedelta(days=days)
metrics = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == current_user.id,
HealthConnectDailyMetrics.date >= start_date
).order_by(HealthConnectDailyMetrics.date.desc()).all()
return [
{
"date": m.date.isoformat(),
"step_count": m.step_count,
"distance_meters": m.distance_meters,
"calories_burned": m.calories_burned,
"sleep_duration_minutes": m.sleep_duration_minutes,
"deep_sleep_minutes": m.deep_sleep_minutes,
"light_sleep_minutes": m.light_sleep_minutes,
"rem_sleep_minutes": m.rem_sleep_minutes,
"avg_heart_rate": m.avg_heart_rate,
"avg_spo2": m.avg_spo2,
"weight": m.weight,
"data_source": m.data_source,
"data_source_name": m.data_source_name
}
for m in metrics
]
+734
View File
@@ -0,0 +1,734 @@
"""
Health Connect Raw Data API - Endpoints for receiving raw health records from Android.
This endpoint receives individual health records (not aggregated) from the Android
companion app and stores them for accurate on-demand aggregation.
OPTIMIZED VERSION: Uses batch inserts and efficient duplicate detection.
"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from sqlalchemy import and_, tuple_
from typing import List, Optional, Set, Tuple
from pydantic import BaseModel
from datetime import datetime
import logging
import time
logger = logging.getLogger(__name__)
from backend.database import get_db
from backend.models.user import User
from backend.models.health_connect import HealthConnectSyncLog
from backend.models.health_connect_raw import (
HCRawSteps,
HCRawHeartRate,
HCRawHeartRateSample,
HCRawRestingHeartRate,
HCRawSleepSession,
HCRawSleepStage,
HCRawDistance,
HCRawCalories,
HCRawOxygenSaturation,
HCRawWeight,
HCRawHeight,
HCRawBodyFat,
HCRawExerciseSession,
HCRawNutrition,
HCRawHydration,
SLEEP_STAGE_TYPES,
)
from backend import auth
router = APIRouter(prefix="/api/health-connect", tags=["health-connect-raw"])
# ==================== REQUEST MODELS ====================
class RawStepsPayload(BaseModel):
"""Individual step record from Health Connect"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
count: int
class HeartRateSamplePayload(BaseModel):
"""Single heart rate sample"""
time: datetime
bpm: int
class RawHeartRatePayload(BaseModel):
"""Heart rate record with samples"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
samples: List[HeartRateSamplePayload] = []
class RawRestingHeartRatePayload(BaseModel):
"""Resting heart rate record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
bpm: int
class SleepStagePayload(BaseModel):
"""Sleep stage within a session"""
start_time: datetime
end_time: datetime
stage_type: int
stage_name: Optional[str] = None
class RawSleepSessionPayload(BaseModel):
"""Sleep session with stages"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
title: Optional[str] = None
notes: Optional[str] = None
stages: List[SleepStagePayload] = []
class RawDistancePayload(BaseModel):
"""Distance record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
distance_meters: float
class RawCaloriesPayload(BaseModel):
"""Calories record (active or total)"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
calories: float
record_type: str # "active" or "total"
class RawOxygenSaturationPayload(BaseModel):
"""SpO2 record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
percentage: float
class RawWeightPayload(BaseModel):
"""Weight record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
weight_kg: float
class RawHeightPayload(BaseModel):
"""Height record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
height_meters: float
class RawBodyFatPayload(BaseModel):
"""Body fat record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
percentage: float
class RawExerciseSessionPayload(BaseModel):
"""Exercise session"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
exercise_type: int
exercise_type_name: Optional[str] = None
title: Optional[str] = None
notes: Optional[str] = None
distance_meters: Optional[float] = None
calories: Optional[float] = None
avg_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
steps: Optional[int] = None
elevation_gained_meters: Optional[float] = None
class RawNutritionPayload(BaseModel):
"""Nutrition record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
name: Optional[str] = None
meal_type: Optional[int] = None
calories: Optional[float] = None
protein_grams: Optional[float] = None
carbohydrates_grams: Optional[float] = None
fat_grams: Optional[float] = None
fiber_grams: Optional[float] = None
sugar_grams: Optional[float] = None
class RawHydrationPayload(BaseModel):
"""Hydration record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
volume_liters: float
class RawDataSyncRequest(BaseModel):
"""Complete raw data sync request from Android app"""
device_id: str
sync_timestamp: str = ""
# All record types
steps_records: List[RawStepsPayload] = []
heart_rate_records: List[RawHeartRatePayload] = []
resting_hr_records: List[RawRestingHeartRatePayload] = []
sleep_sessions: List[RawSleepSessionPayload] = []
distance_records: List[RawDistancePayload] = []
calories_records: List[RawCaloriesPayload] = []
spo2_records: List[RawOxygenSaturationPayload] = []
weight_records: List[RawWeightPayload] = []
height_records: List[RawHeightPayload] = []
body_fat_records: List[RawBodyFatPayload] = []
exercise_sessions: List[RawExerciseSessionPayload] = []
nutrition_records: List[RawNutritionPayload] = []
hydration_records: List[RawHydrationPayload] = []
class RawSyncResponse(BaseModel):
"""Response for raw data sync"""
status: str
sync_id: int
records_synced: dict # Count per record type
message: str
# ==================== OPTIMIZED HELPER FUNCTIONS ====================
def get_existing_record_ids(db: Session, model, user_id: int, record_ids: List[str]) -> Set[str]:
"""
Batch query to find which record_ids already exist for the user.
Returns a set of existing record_ids.
"""
if not record_ids:
return set()
# Process in batches to avoid query limits
batch_size = 500
existing_ids = set()
for i in range(0, len(record_ids), batch_size):
batch = record_ids[i:i + batch_size]
results = db.query(model.record_id).filter(
model.user_id == user_id,
model.record_id.in_(batch)
).all()
existing_ids.update(r[0] for r in results)
return existing_ids
def batch_insert_simple_records(db: Session, model, user_id: int, records: list,
sync_id: int, existing_ids: Set[str]) -> int:
"""
Batch insert records, skipping those that already exist.
Also deduplicates records within the payload itself.
Returns count of new records inserted.
"""
new_records = []
now = datetime.utcnow()
seen_ids = set() # Track record_ids we've already processed in this batch
for record in records:
# Skip if already exists in database
if record.record_id in existing_ids:
continue
# Skip if we've already seen this record_id in this payload (dedupe)
if record.record_id in seen_ids:
continue
seen_ids.add(record.record_id)
data = record.model_dump()
data['user_id'] = user_id
data['sync_id'] = sync_id
data['synced_at'] = now
new_records.append(model(**data))
if new_records:
db.bulk_save_objects(new_records)
return len(new_records)
# ==================== ENDPOINTS ====================
@router.post("/sync-raw", response_model=RawSyncResponse, status_code=status.HTTP_200_OK)
async def receive_raw_health_data(
request: RawDataSyncRequest,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Receive raw health records from Android companion app.
OPTIMIZED: Uses batch queries and bulk inserts for much better performance.
"""
start_time = time.time()
# Count total records (excluding HR samples for now)
total_records = (
len(request.steps_records) +
len(request.heart_rate_records) +
len(request.resting_hr_records) +
len(request.sleep_sessions) +
len(request.distance_records) +
len(request.calories_records) +
len(request.spo2_records) +
len(request.weight_records) +
len(request.height_records) +
len(request.body_fat_records) +
len(request.exercise_sessions) +
len(request.nutrition_records) +
len(request.hydration_records)
)
# Count HR samples
total_hr_samples = sum(len(hr.samples) for hr in request.heart_rate_records)
logger.info(f"=== Raw Data Sync from user {current_user.username} ===")
logger.info(f"Device: {request.device_id}, Records: {total_records}, HR Samples: {total_hr_samples}")
# Create sync log
sync_log = HealthConnectSyncLog(
user_id=current_user.id,
device_id=request.device_id,
records_pushed=total_records,
status="processing"
)
db.add(sync_log)
db.flush()
# Track counts
counts = {
"steps": 0,
"heart_rate": 0,
"heart_rate_samples": 0,
"resting_hr": 0,
"sleep_sessions": 0,
"sleep_stages": 0,
"distance": 0,
"calories": 0,
"spo2": 0,
"weight": 0,
"height": 0,
"body_fat": 0,
"exercise": 0,
"nutrition": 0,
"hydration": 0,
}
try:
now = datetime.utcnow()
# === STEPS ===
if request.steps_records:
step_ids = [r.record_id for r in request.steps_records]
existing_step_ids = get_existing_record_ids(db, HCRawSteps, current_user.id, step_ids)
counts["steps"] = batch_insert_simple_records(
db, HCRawSteps, current_user.id, request.steps_records,
sync_log.id, existing_step_ids
)
logger.info(f"Steps: {counts['steps']} new of {len(request.steps_records)} total")
# === DISTANCE ===
if request.distance_records:
dist_ids = [r.record_id for r in request.distance_records]
existing_dist_ids = get_existing_record_ids(db, HCRawDistance, current_user.id, dist_ids)
counts["distance"] = batch_insert_simple_records(
db, HCRawDistance, current_user.id, request.distance_records,
sync_log.id, existing_dist_ids
)
logger.info(f"Distance: {counts['distance']} new of {len(request.distance_records)} total")
# === CALORIES ===
if request.calories_records:
cal_ids = [r.record_id for r in request.calories_records]
existing_cal_ids = get_existing_record_ids(db, HCRawCalories, current_user.id, cal_ids)
counts["calories"] = batch_insert_simple_records(
db, HCRawCalories, current_user.id, request.calories_records,
sync_log.id, existing_cal_ids
)
logger.info(f"Calories: {counts['calories']} new of {len(request.calories_records)} total")
# === RESTING HR ===
if request.resting_hr_records:
rhr_ids = [r.record_id for r in request.resting_hr_records]
existing_rhr_ids = get_existing_record_ids(db, HCRawRestingHeartRate, current_user.id, rhr_ids)
counts["resting_hr"] = batch_insert_simple_records(
db, HCRawRestingHeartRate, current_user.id, request.resting_hr_records,
sync_log.id, existing_rhr_ids
)
logger.info(f"Resting HR: {counts['resting_hr']} new of {len(request.resting_hr_records)} total")
# === SpO2 ===
if request.spo2_records:
spo2_ids = [r.record_id for r in request.spo2_records]
existing_spo2_ids = get_existing_record_ids(db, HCRawOxygenSaturation, current_user.id, spo2_ids)
counts["spo2"] = batch_insert_simple_records(
db, HCRawOxygenSaturation, current_user.id, request.spo2_records,
sync_log.id, existing_spo2_ids
)
logger.info(f"SpO2: {counts['spo2']} new of {len(request.spo2_records)} total")
# === WEIGHT ===
if request.weight_records:
weight_ids = [r.record_id for r in request.weight_records]
existing_weight_ids = get_existing_record_ids(db, HCRawWeight, current_user.id, weight_ids)
counts["weight"] = batch_insert_simple_records(
db, HCRawWeight, current_user.id, request.weight_records,
sync_log.id, existing_weight_ids
)
logger.info(f"Weight: {counts['weight']} new of {len(request.weight_records)} total")
# === HEIGHT ===
if request.height_records:
height_ids = [r.record_id for r in request.height_records]
existing_height_ids = get_existing_record_ids(db, HCRawHeight, current_user.id, height_ids)
counts["height"] = batch_insert_simple_records(
db, HCRawHeight, current_user.id, request.height_records,
sync_log.id, existing_height_ids
)
logger.info(f"Height: {counts['height']} new of {len(request.height_records)} total")
# === BODY FAT ===
if request.body_fat_records:
bf_ids = [r.record_id for r in request.body_fat_records]
existing_bf_ids = get_existing_record_ids(db, HCRawBodyFat, current_user.id, bf_ids)
counts["body_fat"] = batch_insert_simple_records(
db, HCRawBodyFat, current_user.id, request.body_fat_records,
sync_log.id, existing_bf_ids
)
logger.info(f"Body Fat: {counts['body_fat']} new of {len(request.body_fat_records)} total")
# === NUTRITION ===
if request.nutrition_records:
nut_ids = [r.record_id for r in request.nutrition_records]
existing_nut_ids = get_existing_record_ids(db, HCRawNutrition, current_user.id, nut_ids)
counts["nutrition"] = batch_insert_simple_records(
db, HCRawNutrition, current_user.id, request.nutrition_records,
sync_log.id, existing_nut_ids
)
logger.info(f"Nutrition: {counts['nutrition']} new of {len(request.nutrition_records)} total")
# === HYDRATION ===
if request.hydration_records:
hyd_ids = [r.record_id for r in request.hydration_records]
existing_hyd_ids = get_existing_record_ids(db, HCRawHydration, current_user.id, hyd_ids)
counts["hydration"] = batch_insert_simple_records(
db, HCRawHydration, current_user.id, request.hydration_records,
sync_log.id, existing_hyd_ids
)
logger.info(f"Hydration: {counts['hydration']} new of {len(request.hydration_records)} total")
# === EXERCISE SESSIONS ===
if request.exercise_sessions:
ex_ids = [r.record_id for r in request.exercise_sessions]
existing_ex_ids = get_existing_record_ids(db, HCRawExerciseSession, current_user.id, ex_ids)
counts["exercise"] = batch_insert_simple_records(
db, HCRawExerciseSession, current_user.id, request.exercise_sessions,
sync_log.id, existing_ex_ids
)
logger.info(f"Exercise: {counts['exercise']} new of {len(request.exercise_sessions)} total")
# === HEART RATE (with samples - needs special handling) ===
if request.heart_rate_records:
hr_ids = [r.record_id for r in request.heart_rate_records]
existing_hr_ids = get_existing_record_ids(db, HCRawHeartRate, current_user.id, hr_ids)
# Insert new HR records
new_hr_records = []
hr_records_for_samples = [] # Track which records need samples
for record in request.heart_rate_records:
if record.record_id in existing_hr_ids:
continue
data = record.model_dump(exclude={"samples"})
data['user_id'] = current_user.id
data['sync_id'] = sync_log.id
data['synced_at'] = now
hr_obj = HCRawHeartRate(**data)
new_hr_records.append(hr_obj)
hr_records_for_samples.append((hr_obj, record.samples))
if new_hr_records:
db.bulk_save_objects(new_hr_records, return_defaults=True)
db.flush() # Get IDs
counts["heart_rate"] = len(new_hr_records)
# Now insert samples in batches
all_samples = []
for hr_obj, samples in hr_records_for_samples:
for sample in samples:
all_samples.append(HCRawHeartRateSample(
record_id=hr_obj.id,
time=sample.time,
bpm=sample.bpm
))
if all_samples:
# Insert in batches of 1000
batch_size = 1000
for i in range(0, len(all_samples), batch_size):
batch = all_samples[i:i + batch_size]
db.bulk_save_objects(batch)
counts["heart_rate_samples"] = len(all_samples)
logger.info(f"Heart Rate: {counts['heart_rate']} records, {counts['heart_rate_samples']} samples")
# === SLEEP SESSIONS (with stages) ===
if request.sleep_sessions:
sleep_ids = [r.record_id for r in request.sleep_sessions]
existing_sleep_ids = get_existing_record_ids(db, HCRawSleepSession, current_user.id, sleep_ids)
new_sleep_records = []
sleep_records_for_stages = []
for record in request.sleep_sessions:
if record.record_id in existing_sleep_ids:
continue
data = record.model_dump(exclude={"stages"})
data['user_id'] = current_user.id
data['sync_id'] = sync_log.id
data['synced_at'] = now
sleep_obj = HCRawSleepSession(**data)
new_sleep_records.append(sleep_obj)
sleep_records_for_stages.append((sleep_obj, record.stages))
if new_sleep_records:
db.bulk_save_objects(new_sleep_records, return_defaults=True)
db.flush()
counts["sleep_sessions"] = len(new_sleep_records)
# Insert stages
all_stages = []
for sleep_obj, stages in sleep_records_for_stages:
for stage in stages:
stage_name = stage.stage_name or SLEEP_STAGE_TYPES.get(stage.stage_type, "unknown")
all_stages.append(HCRawSleepStage(
session_id=sleep_obj.id,
start_time=stage.start_time,
end_time=stage.end_time,
stage_type=stage.stage_type,
stage_name=stage_name
))
if all_stages:
db.bulk_save_objects(all_stages)
counts["sleep_stages"] = len(all_stages)
logger.info(f"Sleep: {counts['sleep_sessions']} sessions, {counts['sleep_stages']} stages")
# Update sync log
sync_log.status = "success"
sync_log.records_pushed = sum(counts.values())
db.commit()
elapsed = time.time() - start_time
logger.info(f"=== Raw Sync Complete in {elapsed:.2f}s ===")
logger.info(f"Records synced: {counts}")
return RawSyncResponse(
status="success",
sync_id=sync_log.id,
records_synced=counts,
message=f"Successfully synced {sum(counts.values())} raw records in {elapsed:.2f}s"
)
except Exception as e:
logger.error(f"Raw sync failed: {str(e)}", exc_info=True)
db.rollback()
sync_log.status = "error"
sync_log.error_message = str(e)[:500] # Limit error message length
db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Raw sync failed: {str(e)}"
)
@router.get("/raw/steps", status_code=status.HTTP_200_OK)
async def get_raw_steps(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: int = 1000,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get raw step records for a date range."""
from datetime import timedelta
if not end_date:
end_date = datetime.utcnow()
if not start_date:
start_date = end_date - timedelta(days=7)
records = db.query(HCRawSteps).filter(
HCRawSteps.user_id == current_user.id,
HCRawSteps.start_time >= start_date,
HCRawSteps.start_time <= end_date
).order_by(HCRawSteps.start_time.desc()).limit(limit).all()
return [
{
"id": r.id,
"record_id": r.record_id,
"data_source": r.data_source,
"data_source_name": r.data_source_name,
"start_time": r.start_time.isoformat() if r.start_time else None,
"end_time": r.end_time.isoformat() if r.end_time else None,
"count": r.count
}
for r in records
]
@router.get("/raw/heart-rate", status_code=status.HTTP_200_OK)
async def get_raw_heart_rate(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
include_samples: bool = False,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get raw heart rate records for a date range."""
from datetime import timedelta
if not end_date:
end_date = datetime.utcnow()
if not start_date:
start_date = end_date - timedelta(days=1)
records = db.query(HCRawHeartRate).filter(
HCRawHeartRate.user_id == current_user.id,
HCRawHeartRate.start_time >= start_date,
HCRawHeartRate.start_time <= end_date
).order_by(HCRawHeartRate.start_time.desc()).limit(limit).all()
result = []
for r in records:
record_dict = {
"id": r.id,
"record_id": r.record_id,
"data_source": r.data_source,
"start_time": r.start_time.isoformat() if r.start_time else None,
"end_time": r.end_time.isoformat() if r.end_time else None,
}
if include_samples:
record_dict["samples"] = [
{"time": s.time.isoformat(), "bpm": s.bpm}
for s in r.samples
]
else:
record_dict["sample_count"] = len(r.samples)
result.append(record_dict)
return result
@router.get("/raw/stats", status_code=status.HTTP_200_OK)
async def get_raw_data_stats(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get statistics about raw data stored for current user."""
from sqlalchemy import func
stats = {
"steps": db.query(func.count(HCRawSteps.id)).filter(
HCRawSteps.user_id == current_user.id
).scalar(),
"heart_rate": db.query(func.count(HCRawHeartRate.id)).filter(
HCRawHeartRate.user_id == current_user.id
).scalar(),
"heart_rate_samples": db.query(func.count(HCRawHeartRateSample.id)).join(
HCRawHeartRate
).filter(
HCRawHeartRate.user_id == current_user.id
).scalar(),
"resting_hr": db.query(func.count(HCRawRestingHeartRate.id)).filter(
HCRawRestingHeartRate.user_id == current_user.id
).scalar(),
"sleep_sessions": db.query(func.count(HCRawSleepSession.id)).filter(
HCRawSleepSession.user_id == current_user.id
).scalar(),
"distance": db.query(func.count(HCRawDistance.id)).filter(
HCRawDistance.user_id == current_user.id
).scalar(),
"calories": db.query(func.count(HCRawCalories.id)).filter(
HCRawCalories.user_id == current_user.id
).scalar(),
"spo2": db.query(func.count(HCRawOxygenSaturation.id)).filter(
HCRawOxygenSaturation.user_id == current_user.id
).scalar(),
"weight": db.query(func.count(HCRawWeight.id)).filter(
HCRawWeight.user_id == current_user.id
).scalar(),
"exercise": db.query(func.count(HCRawExerciseSession.id)).filter(
HCRawExerciseSession.user_id == current_user.id
).scalar(),
}
# Get date range
oldest_step = db.query(func.min(HCRawSteps.start_time)).filter(
HCRawSteps.user_id == current_user.id
).scalar()
newest_step = db.query(func.max(HCRawSteps.start_time)).filter(
HCRawSteps.user_id == current_user.id
).scalar()
return {
"record_counts": stats,
"total_records": sum(stats.values()),
"date_range": {
"oldest": oldest_step.isoformat() if oldest_step else None,
"newest": newest_step.isoformat() if newest_step else None
}
}
+63
View File
@@ -0,0 +1,63 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from .core.config import settings
from .database import get_db
from .models.user import User
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
"""Get the current authenticated user from JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: Optional[str] = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
return user
+1
View File
@@ -0,0 +1 @@
"""Core module for configuration and security."""
+62
View File
@@ -0,0 +1,62 @@
"""Configuration module using pydantic_settings for environment-based configuration."""
from secrets import token_urlsafe
from typing import List, Union
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Security
SECRET_KEY: str = "dev_secret_key_fixed_for_persistence_change_in_prod"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_MINUTES: int = 43200 # 30 days
# Database
DATABASE_URL: str = "sqlite:///./data/habitforge.db"
# CORS
CORS_ORIGINS: Union[List[str], str] = ["http://localhost:8000", "http://localhost:3000"]
CORS_ALLOW_CREDENTIALS: bool = True
CORS_ALLOW_METHODS: Union[List[str], str] = ["*"]
CORS_ALLOW_HEADERS: Union[List[str], str] = ["*"]
# Google OAuth
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/api/google/callback"
# Rate Limiting
RATE_LIMIT_ENABLED: bool = True
RATE_LIMIT_TIMES: int = 60
RATE_LIMIT_SECONDS: int = 60
# Application
APP_NAME: str = "HabitForge API"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
# Health Data Provider Selection
# Options: "health_connect"
HEALTH_PROVIDER: str = "health_connect"
@field_validator("CORS_ORIGINS", "CORS_ALLOW_METHODS", "CORS_ALLOW_HEADERS", mode="before")
@classmethod
def assemble_cors_list(cls, v: Union[str, List[str]]) -> List[str]:
"""Convert a comma-separated string into a list of strings if necessary."""
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",") if i.strip()]
return v
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore"
)
# Global settings instance
settings = Settings()
+29
View File
@@ -0,0 +1,29 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
from .core.config import settings
# Ensure data directory exists for SQLite
if settings.DATABASE_URL.startswith("sqlite"):
os.makedirs("./data", exist_ok=True)
# Create engine with appropriate settings based on database type
if settings.DATABASE_URL.startswith("sqlite"):
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False}
)
else:
engine = create_engine(settings.DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+16
View File
@@ -0,0 +1,16 @@
"""Integrations module for external APIs."""
from backend.integrations.health_provider import (
HealthDataProvider,
DataSource,
HealthProviderFactory
)
# Import providers to register them with the factory
# from backend.integrations import google_fit_provider
from backend.integrations import health_connect_provider
__all__ = [
"HealthDataProvider",
"DataSource",
"HealthProviderFactory",
]
@@ -0,0 +1,162 @@
"""
Health Connect Provider - Implementation for Health Connect API.
This module provides the Health Connect implementation that reads data
pushed from the Android companion app via the /api/health-connect/sync endpoint.
"""
import datetime
from typing import Dict, Any, List, Optional
from backend.integrations.health_provider import (
HealthDataProvider,
DataSource,
HealthProviderFactory
)
class HealthConnectProvider(HealthDataProvider):
"""
Health Connect implementation of HealthDataProvider.
This provider reads data that has been pushed from an Android companion app
via the /api/health-connect/sync endpoint and stored in the database.
"""
@property
def source(self) -> DataSource:
return DataSource.HEALTH_CONNECT
def get_auth_url(self) -> str:
"""
Health Connect doesn't use OAuth - permissions are granted on-device.
Returns a URL to download the Android companion app.
"""
# TODO: Replace with actual Play Store URL when app is published
return "https://play.google.com/store/apps/details?id=com.habitforge.companion"
async def fetch_daily_metrics(
self,
access_token: Optional[str], # Not used for Health Connect, but required by interface
days: int = 7,
timezone: str = "UTC",
user_id: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Fetch daily metrics from Health Connect data store.
Reads data that was pushed from the Android companion app.
"""
if not user_id:
print("[HealthConnect] ERROR: user_id is required")
return []
print(f"[HealthConnect] Fetching daily metrics for user {user_id}, last {days} days")
from backend.database import SessionLocal
from backend.models.health_connect import HealthConnectDailyMetrics
from datetime import timedelta, date as date_type
db = SessionLocal()
try:
start_date = date_type.today() - timedelta(days=days)
metrics = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == user_id,
HealthConnectDailyMetrics.date >= start_date
).order_by(HealthConnectDailyMetrics.date.desc()).all()
if not metrics:
print(f"[HealthConnect] No data found. Ensure Android app has synced data.")
return []
print(f"[HealthConnect] Found {len(metrics)} days of data")
# Convert to standard format
return [
{
"date": m.date,
"step_count": m.step_count or 0,
"calories_burned": m.calories_burned or 0.0,
"distance_meters": m.distance_meters or 0.0,
"sleep_duration_minutes": m.sleep_duration_minutes or 0,
"deep_sleep_minutes": m.deep_sleep_minutes or 0,
"light_sleep_minutes": m.light_sleep_minutes or 0,
"rem_sleep_minutes": m.rem_sleep_minutes or 0,
"awake_duration_minutes": m.awake_duration_minutes or 0,
"avg_heart_rate": m.avg_heart_rate or 0,
"min_heart_rate": m.min_heart_rate or 0,
"max_heart_rate": m.max_heart_rate or 0,
"avg_spo2": m.avg_spo2 or 0.0,
"pai_score": 0.0, # Calculate if needed
"weight": m.weight,
"resting_heart_rate": m.resting_heart_rate,
"hrv": None
}
for m in metrics
]
finally:
db.close()
async def fetch_workouts(
self,
access_token: Optional[str],
days: int = 30
) -> List[Dict[str, Any]]:
"""
Fetch exercise sessions from Health Connect data store.
"""
# Note: We need user_id but the interface doesn't pass it for workouts
# This is a limitation we'll need to address in the interface
print(f"[HealthConnect] fetch_workouts called")
from backend.database import SessionLocal
from backend.models.health_connect import HealthConnectExerciseSession
from datetime import timedelta
db = SessionLocal()
try:
start_date = datetime.datetime.now() - timedelta(days=days)
# Query all sessions (we can't filter by user without user_id)
# In practice, this would be called with user context
sessions = db.query(HealthConnectExerciseSession).filter(
HealthConnectExerciseSession.start_time >= start_date
).order_by(HealthConnectExerciseSession.start_time.desc()).all()
return [
{
"start_time": s.start_time,
"end_time": s.end_time,
"activity_type": s.exercise_type_name,
"activity_type_id": s.exercise_type,
"duration_seconds": s.duration_seconds,
"distance_meters": s.distance_meters or 0.0,
"calories": s.calories_burned or 0.0,
"avg_hr": s.avg_heart_rate,
"max_hr": s.max_heart_rate,
"min_hr": s.min_heart_rate
}
for s in sessions
]
finally:
db.close()
async def fetch_sleep_sessions(
self,
access_token: Optional[str],
days: int = 30
) -> List[Dict[str, Any]]:
"""
Fetch sleep sessions from Health Connect data store.
Note: Health Connect stores sleep as part of daily metrics.
This returns an empty list as sleep is handled in fetch_daily_metrics.
"""
print(f"[HealthConnect] fetch_sleep_sessions called")
# Sleep data is included in daily metrics for Health Connect
return []
# Register the provider with the factory
HealthProviderFactory.register("health_connect", HealthConnectProvider)
+176
View File
@@ -0,0 +1,176 @@
"""
Health Data Provider - Abstract interface for health data sources.
This module provides an abstraction layer that allows switching between
different health data backends (Google Fit, Health Connect, etc.) without
changing the API layer.
"""
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional
from datetime import datetime
from enum import Enum
class DataSource(str, Enum):
"""Enum for tracking data provenance."""
HEALTH_CONNECT = "health_connect"
MANUAL = "manual"
class HealthDataProvider(ABC):
"""
Abstract base class for health data providers.
Implementations must provide methods to fetch daily metrics,
workout sessions, and sleep data from their respective sources.
"""
@property
@abstractmethod
def source(self) -> DataSource:
"""Return the data source identifier."""
pass
@abstractmethod
async def fetch_daily_metrics(
self,
access_token: Optional[str],
days: int = 7,
timezone: str = "UTC",
user_id: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Fetch aggregated daily health metrics.
Args:
access_token: OAuth token or API key
days: Number of days to fetch
timezone: User's timezone for day boundaries
user_id: Optional user ID for logging
Returns:
List of daily metric dictionaries with keys:
- date: date object
- step_count: int
- calories_burned: float
- distance_meters: float
- sleep_duration_minutes: int
- deep_sleep_minutes: int
- light_sleep_minutes: int
- rem_sleep_minutes: int
- awake_duration_minutes: int
- avg_heart_rate: int
- min_heart_rate: int
- max_heart_rate: int
- avg_spo2: float
- pai_score: float
"""
pass
@abstractmethod
async def fetch_workouts(
self,
access_token: Optional[str],
days: int = 30
) -> List[Dict[str, Any]]:
"""
Fetch workout sessions.
Args:
access_token: OAuth token or API key
days: Number of days to fetch
Returns:
List of workout dictionaries with keys:
- start_time: datetime
- end_time: datetime
- activity_type: str (human readable)
- activity_type_id: int (source-specific ID)
- duration_seconds: int
- distance_meters: float
- calories: float
- avg_hr: int
- max_hr: int
- min_hr: int
"""
pass
@abstractmethod
async def fetch_sleep_sessions(
self,
access_token: Optional[str],
days: int = 30
) -> List[Dict[str, Any]]:
"""
Fetch sleep sessions for total duration validation.
Args:
access_token: OAuth token or API key
days: Number of days to fetch
Returns:
List of sleep session dictionaries with keys:
- date: date object (wake-up date)
- duration_minutes: int
- start_time: datetime
- end_time: datetime
- type: str
"""
pass
@abstractmethod
def get_auth_url(self) -> str:
"""
Get the OAuth authorization URL for this provider.
Returns:
Authorization URL string
"""
pass
class HealthProviderFactory:
"""
Factory for creating health data provider instances.
Uses the HEALTH_PROVIDER environment variable to determine
which provider to instantiate.
"""
_providers: Dict[str, type] = {}
@classmethod
def register(cls, name: str, provider_class: type):
"""Register a provider class with a name."""
cls._providers[name] = provider_class
@classmethod
def get_provider(cls, name: Optional[str] = None) -> HealthDataProvider:
"""
Get a provider instance by name.
Args:
name: Provider name. If None, uses HEALTH_PROVIDER env var.
Returns:
HealthDataProvider instance
Raises:
ValueError: If provider name is not registered
"""
from backend.core.config import settings
provider_name = name or getattr(settings, 'HEALTH_PROVIDER', 'health_connect')
if provider_name not in cls._providers:
available = list(cls._providers.keys())
raise ValueError(
f"Unknown provider '{provider_name}'. Available: {available}"
)
return cls._providers[provider_name]()
@classmethod
def list_providers(cls) -> List[str]:
"""List all registered provider names."""
return list(cls._providers.keys())
+434
View File
@@ -0,0 +1,434 @@
"""
Zepp Data Import Module
Imports health data from Zepp/Mi Band/Amazfit export files into HabitForge database.
Supports: Activity, Sleep, Heart Rate, Body measurements, and Sport/Workouts.
"""
import csv
import glob
import os
import datetime
from sqlalchemy.orm import Session
from sqlalchemy import create_engine
from backend.models import DailyHealthMetrics, User, WorkoutSession
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
from backend.database import SessionLocal, engine, Base
# --- Configuration ---
EXPORT_DIR = "export-zepp"
USER_ID = 1 # Default user ID since we are single user for now or running locally
def parse_date(date_str):
"""Parse YYYY-MM-DD date string."""
if not date_str:
return None
try:
return datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return None
def parse_datetime(dt_str):
"""Parse datetime string with timezone (e.g., 2025-12-28 18:25:54+0000)."""
if not dt_str:
return None
try:
# Remove timezone for simplicity
clean_str = dt_str.split('+')[0].split('-0')[0]
return datetime.datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def get_latest_export_dir(base_dir):
"""Find the numbered directory inside export-zepp."""
subdirs = glob.glob(os.path.join(base_dir, "*"))
if not subdirs:
return None
# Return the most recent one (sorted by name, which includes timestamp)
return sorted(subdirs)[-1]
def import_activity(session, export_path):
"""Import daily activity summary (steps, distance, calories)."""
activity_dir = os.path.join(export_path, "ACTIVITY")
csv_files = glob.glob(os.path.join(activity_dir, "*.csv"))
if not csv_files:
print("No Activity CSV found.")
return
count = 0
for csv_file in csv_files:
print(f"Processing Activity: {csv_file}")
with open(csv_file, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
d_date = parse_date(row.get('date'))
if not d_date:
continue
steps = int(row.get('steps', 0))
distance = float(row.get('distance', 0))
calories = float(row.get('calories', 0))
# Upsert
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
# Overwrite with Zepp data as it's authoritative
if steps > 0:
metric.step_count = steps
if distance > 0:
metric.distance_meters = distance
if calories > 0:
metric.calories_burned = calories
count += 1
session.commit()
print(f" Imported {count} activity records.")
def import_sleep(session, export_path):
"""Import sleep data with phases (deep, light, REM, awake)."""
sleep_dir = os.path.join(export_path, "SLEEP")
csv_files = glob.glob(os.path.join(sleep_dir, "*.csv"))
count = 0
for csv_file in csv_files:
print(f"Processing Sleep: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
d_date = parse_date(row.get('date'))
if not d_date:
continue
try:
deep = int(row.get('deepSleepTime', 0))
light = int(row.get('shallowSleepTime', 0))
wake = int(row.get('wakeTime', 0))
rem = int(row.get('REMTime', 0))
except ValueError:
continue
total_sleep = deep + light + rem
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
if total_sleep > 0:
metric.sleep_duration_minutes = total_sleep
metric.deep_sleep_minutes = deep
metric.light_sleep_minutes = light
metric.rem_sleep_minutes = rem
metric.awake_duration_minutes = wake
count += 1
session.commit()
print(f" Imported {count} sleep records.")
def import_heart_rate(session, export_path):
"""Import manual heart rate readings."""
hr_dir = os.path.join(export_path, "HEARTRATE")
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
daily_hr = {} # date -> [hr_values]
for csv_file in csv_files:
print(f"Processing Heart Rate: {csv_file}")
with open(csv_file, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
ts_str = row.get('time')
hr_val = int(row.get('heartRate', 0))
if not ts_str or hr_val <= 0:
continue
try:
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
d_date = dt.date()
if d_date not in daily_hr:
daily_hr[d_date] = []
daily_hr[d_date].append(hr_val)
except Exception:
pass
# Aggregate
for d_date, values in daily_hr.items():
if not values:
continue
avg_hr = sum(values) // len(values)
min_hr = min(values)
max_hr = max(values)
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
metric.avg_heart_rate = avg_hr
metric.min_heart_rate = min_hr
metric.max_heart_rate = max_hr
session.commit()
print(f" Imported heart rate data for {len(daily_hr)} days.")
def import_heart_rate_auto(session, export_path):
"""Import automatic heart rate monitoring data."""
hr_dir = os.path.join(export_path, "HEARTRATE_AUTO")
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
if not csv_files:
print("No HEARTRATE_AUTO CSV found.")
return
daily_hr = {}
for csv_file in csv_files:
print(f"Processing Heart Rate Auto: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
date_str = row.get('date')
hr_val = int(row.get('heartRate', 0))
if not date_str or hr_val <= 0:
continue
try:
d_date = parse_date(date_str)
if not d_date:
continue
if d_date not in daily_hr:
daily_hr[d_date] = []
daily_hr[d_date].append(hr_val)
except Exception:
pass
# Aggregate and Save
for d_date, values in daily_hr.items():
if not values:
continue
avg_hr = sum(values) // len(values)
min_hr = min(values)
max_hr = max(values)
rhr = min_hr # Resting HR approximation
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
metric.avg_heart_rate = avg_hr
metric.min_heart_rate = min_hr
metric.max_heart_rate = max_hr
metric.resting_heart_rate = rhr
session.commit()
print(f" Imported auto heart rate data for {len(daily_hr)} days.")
def import_body(session, export_path):
"""Import body measurements (weight, etc.)."""
body_dir = os.path.join(export_path, "BODY")
csv_files = glob.glob(os.path.join(body_dir, "*.csv"))
count = 0
for csv_file in csv_files:
print(f"Processing Body: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
ts_str = row.get('time')
w_str = row.get('weight', '0')
weight = float(w_str)
if not ts_str or weight <= 0:
continue
try:
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
d_date = dt.date()
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
metric.weight = weight
count += 1
except Exception:
pass
session.commit()
print(f" Imported {count} body measurements.")
def import_sport(session, export_path):
"""Import sport/workout sessions."""
sport_dir = os.path.join(export_path, "SPORT")
csv_files = glob.glob(os.path.join(sport_dir, "*.csv"))
if not csv_files:
print("No SPORT CSV found.")
return
count = 0
for csv_file in csv_files:
print(f"Processing Sport: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
# Columns: type, startTime, sportTime(s), maxPace(/meter), minPace(/meter),
# distance(m), avgPace(/meter), calories(kcal)
for row in reader:
try:
type_id = int(row.get('type', 0))
start_time_str = row.get('startTime')
duration_seconds = int(row.get('sportTime(s)', 0))
distance = float(row.get('distance(m)', 0))
calories = float(row.get('calories(kcal)', 0))
max_pace = float(row.get('maxPace(/meter)', 0))
min_pace = float(row.get('minPace(/meter)', 0))
avg_pace = float(row.get('avgPace(/meter)', 0))
start_time = parse_datetime(start_time_str)
if not start_time:
continue
end_time = start_time + datetime.timedelta(seconds=duration_seconds)
activity_name = WorkoutSession.get_activity_name(type_id)
# Check if workout already exists (by start time and user)
existing = session.query(WorkoutSession).filter_by(
user_id=USER_ID, start_time=start_time
).first()
if existing:
# Update existing
existing.activity_type = activity_name
existing.activity_type_id = type_id
existing.duration_seconds = duration_seconds
existing.distance_meters = distance
existing.calories = calories
existing.avg_pace = avg_pace if avg_pace > 0 else None
existing.max_pace = max_pace if max_pace > 0 else None
existing.min_pace = min_pace if min_pace > 0 else None
else:
# Create new
workout = WorkoutSession(
user_id=USER_ID,
start_time=start_time,
end_time=end_time,
activity_type=activity_name,
activity_type_id=type_id,
duration_seconds=duration_seconds,
distance_meters=distance,
calories=calories,
avg_pace=avg_pace if avg_pace > 0 else None,
max_pace=max_pace if max_pace > 0 else None,
min_pace=min_pace if min_pace > 0 else None
)
session.add(workout)
count += 1
except Exception as e:
print(f" Error processing workout: {e}")
continue
session.commit()
print(f" Imported {count} workout sessions.")
def calculate_pai_scores(session):
"""Calculate PAI scores for all days based on activity and heart rate data."""
print("Calculating PAI scores...")
metrics = session.query(DailyHealthMetrics).filter_by(user_id=USER_ID).all()
for metric in metrics:
# Simple PAI calculation based on activity
# Real PAI requires continuous HR data, this is an approximation
pai = 0.0
# Base PAI from steps (rough approximation)
if metric.step_count:
pai += metric.step_count / 1000 # 1 PAI per 1000 steps
# Bonus from calories burned (beyond baseline)
if metric.calories_burned and metric.calories_burned > 0:
pai += metric.calories_burned / 50 # 1 PAI per 50 cal
# Bonus from elevated heart rate activities
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
intensity_bonus = (metric.avg_heart_rate - 100) / 10
pai += intensity_bonus
metric.pai_score = round(min(pai, 100), 1) # Cap at 100
session.commit()
print(f" Updated PAI scores for {len(metrics)} days.")
def main():
"""Main import function."""
print("=" * 60)
print("Starting Zepp Data Import...")
print("=" * 60)
session = SessionLocal()
try:
base_export = get_latest_export_dir(EXPORT_DIR)
if not base_export:
print(f"No export directory found in {EXPORT_DIR}")
return
print(f"Using export directory: {base_export}\n")
# Import all data types
import_activity(session, base_export)
import_sleep(session, base_export)
import_heart_rate(session, base_export)
import_heart_rate_auto(session, base_export)
import_body(session, base_export)
import_sport(session, base_export)
# Calculate derived metrics
calculate_pai_scores(session)
print("\n" + "=" * 60)
print("Import completed successfully!")
print("=" * 60)
except Exception as e:
print(f"\nAn error occurred: {e}")
import traceback
traceback.print_exc()
finally:
session.close()
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
import os
from backend.core.config import settings
from backend.api.v1 import (
auth as auth_router,
challenges as challenges_router,
health as health_router,
# google as google_router,
health_connect as health_connect_router,
health_connect_raw as health_connect_raw_router,
health_analytics as health_analytics_router
)
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
# Rate limiting setup
limiter = Limiter(key_func=get_remote_address)
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Create database tables on startup
@app.on_event("startup")
def on_startup():
from backend.database import Base, engine
import backend.models # This registers all models in the namespace for Base.metadata.create_all
Base.metadata.create_all(bind=engine)
# CORS - Use configured origins
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
allow_methods=settings.CORS_ALLOW_METHODS,
allow_headers=settings.CORS_ALLOW_HEADERS,
)
# Include API v1 routers
app.include_router(auth_router.router)
app.include_router(challenges_router.router)
app.include_router(health_router.router)
# app.include_router(google_router.router)
app.include_router(health_connect_router.router)
app.include_router(health_connect_raw_router.router)
app.include_router(health_analytics_router.router)
# Middleware to drop unexpected WebSocket connections (Fixes StaticFiles crash)
@app.middleware("http")
async def reject_websockets(request, call_next):
return await call_next(request)
from starlette.websockets import WebSocket
@app.websocket("/{path:path}")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
await websocket.close()
@app.get("/api/static/images")
def get_static_images():
"""Retrieve list of available static images."""
static_dir = os.path.join("frontend", "static")
images = []
if os.path.exists(static_dir):
for filename in os.listdir(static_dir):
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
images.append(filename)
return images
# Mount static files last to avoid intercepting API calls
app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend")
+179
View File
@@ -0,0 +1,179 @@
from sqlalchemy import Column, Integer, String, Date, Boolean, ForeignKey, Text, Float, DateTime
from sqlalchemy.orm import relationship
from .database import Base
from pydantic import BaseModel
from typing import Optional, List
from datetime import date
# --- SQLAlchemy Models ---
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
hashed_password = Column(String)
challenges = relationship("Challenge", back_populates="owner")
health_metrics = relationship("DailyHealthMetrics", back_populates="user")
workouts = relationship("WorkoutSession", back_populates="user")
class Challenge(Base):
__tablename__ = "challenges"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
period_type = Column(String) # 'week', 'month', 'year'
daily_target = Column(Integer)
start_date = Column(Date)
end_date = Column(Date)
created_at = Column(Date, default=date.today)
display_order = Column(Integer, default=0)
user_id = Column(Integer, ForeignKey("users.id")) # New FK
unit_type = Column(String, default="reps") # New: 'reps', 'minutes', 'pages', 'km'
rest_days = Column(String, nullable=True) # New: comma-separated list of days
icon = Column(String, default="fa-dumbbell") # New
image_url = Column(String, nullable=True) # New
description = Column(String, nullable=True) # New
frequency_count = Column(Integer, nullable=True) # New
selected_days = Column(String, nullable=True) # New
owner = relationship("User", back_populates="challenges") # New Rel
trackings = relationship("Tracking", back_populates="challenge", cascade="all, delete-orphan")
class Tracking(Base):
__tablename__ = "tracking"
id = Column(Integer, primary_key=True, index=True)
challenge_id = Column(Integer, ForeignKey("challenges.id"))
date = Column(Date)
reps = Column(Integer, default=0)
completed = Column(Boolean, default=False)
notes = Column(Text, nullable=True)
challenge = relationship("Challenge", back_populates="trackings")
class DailyHealthMetrics(Base):
__tablename__ = "daily_health_metrics"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
date = Column(Date, index=True)
step_count = Column(Integer, default=0)
distance_meters = Column(Float, default=0.0)
calories_burned = Column(Float, default=0.0)
sleep_duration_minutes = Column(Integer, default=0)
deep_sleep_minutes = Column(Integer, default=0)
light_sleep_minutes = Column(Integer, default=0) # New
rem_sleep_minutes = Column(Integer, default=0) # New
awake_duration_minutes = Column(Integer, default=0)
avg_heart_rate = Column(Integer, nullable=True)
min_heart_rate = Column(Integer, nullable=True)
max_heart_rate = Column(Integer, nullable=True)
avg_spo2 = Column(Float, nullable=True)
pai_score = Column(Float, default=0.0)
weight = Column(Float, nullable=True) # New
resting_heart_rate = Column(Integer, nullable=True) # New
hrv = Column(Float, nullable=True) # New
user = relationship("User", back_populates="health_metrics")
class WorkoutSession(Base):
__tablename__ = "workout_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
start_time = Column(DateTime)
end_time = Column(DateTime)
activity_type = Column(String)
calories = Column(Float, default=0.0)
avg_hr = Column(Integer, nullable=True)
user = relationship("User", back_populates="workouts")
# --- Pydantic Schemas ---
class TrackingBase(BaseModel):
date: date
reps: int
completed: bool = False
notes: Optional[str] = None
class TrackingCreate(TrackingBase):
challenge_id: int
class TrackingResponse(TrackingBase):
id: int
challenge_id: int
class Config:
from_attributes = True
class ChallengeBase(BaseModel):
name: str
period_type: str
daily_target: int
start_date: date
end_date: Optional[date] = None
unit_type: Optional[str] = "reps"
rest_days: Optional[str] = None
icon: Optional[str] = "fa-dumbbell"
image_url: Optional[str] = None
description: Optional[str] = None
frequency_count: Optional[int] = None # For Monthly frequency (X times per month)
selected_days: Optional[str] = None # For Weekly frequency (Mon,Wed,Fri)
class ChallengeCreate(ChallengeBase):
pass
class ChallengeResponse(ChallengeBase):
id: int
created_at: date
display_order: int = 0
trackings: List[TrackingResponse] = []
class Config:
from_attributes = True
class DailyHealthMetricsBase(BaseModel):
date: date
step_count: Optional[int] = 0
distance_meters: Optional[float] = 0.0
calories_burned: Optional[float] = 0.0
sleep_duration_minutes: Optional[int] = 0
deep_sleep_minutes: Optional[int] = 0
light_sleep_minutes: Optional[int] = 0
rem_sleep_minutes: Optional[int] = 0
awake_duration_minutes: Optional[int] = 0
avg_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
avg_spo2: Optional[float] = None
pai_score: Optional[float] = 0.0
weight: Optional[float] = None
resting_heart_rate: Optional[int] = None
hrv: Optional[float] = None
class DailyHealthMetricsResponse(DailyHealthMetricsBase):
id: int
user_id: int
class Config:
from_attributes = True
# Auth Schemas
class UserCreate(BaseModel):
username: str
password: str
class UserResponse(BaseModel):
id: int
username: str
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str
+59
View File
@@ -0,0 +1,59 @@
"""SQLAlchemy models module."""
from backend.models.user import User
from backend.models.challenge import Challenge
from backend.models.tracking import Tracking
from backend.models.daily_health_metrics import DailyHealthMetrics
from backend.models.workout_session import WorkoutSession
from backend.models.daily_context import DailyContext
from backend.models.health_connect import (
HealthConnectSyncLog,
HealthConnectDailyMetrics,
HealthConnectExerciseSession,
)
from backend.models.health_connect_raw import (
HCRawSteps,
HCRawHeartRate,
HCRawHeartRateSample,
HCRawRestingHeartRate,
HCRawSleepSession,
HCRawSleepStage,
HCRawDistance,
HCRawCalories,
HCRawOxygenSaturation,
HCRawWeight,
HCRawHeight,
HCRawBodyFat,
HCRawExerciseSession,
HCRawNutrition,
HCRawHydration,
HCRawHeartRateVariabilityRmssd,
)
__all__ = [
"User",
"Challenge",
"Tracking",
"DailyHealthMetrics",
"WorkoutSession",
"DailyContext",
"HealthConnectSyncLog",
"HealthConnectDailyMetrics",
"HealthConnectExerciseSession",
# Raw data models
"HCRawSteps",
"HCRawHeartRate",
"HCRawHeartRateSample",
"HCRawRestingHeartRate",
"HCRawSleepSession",
"HCRawSleepStage",
"HCRawDistance",
"HCRawCalories",
"HCRawOxygenSaturation",
"HCRawWeight",
"HCRawHeight",
"HCRawBodyFat",
"HCRawExerciseSession",
"HCRawNutrition",
"HCRawHydration",
"HCRawHeartRateVariabilityRmssd",
]
+30
View File
@@ -0,0 +1,30 @@
"""Challenge SQLAlchemy model."""
from sqlalchemy import Column, Integer, String, Date, ForeignKey
from sqlalchemy.orm import relationship
from datetime import date
from backend.database import Base
class Challenge(Base):
"""Challenge model for tracking habits."""
__tablename__ = "challenges"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
period_type = Column(String) # 'week', 'month', 'year'
daily_target = Column(Integer)
start_date = Column(Date)
end_date = Column(Date)
created_at = Column(Date, default=date.today)
display_order = Column(Integer, default=0)
user_id = Column(Integer, ForeignKey("users.id"))
unit_type = Column(String, default="reps") # 'reps', 'minutes', 'pages', 'km'
rest_days = Column(String, nullable=True) # comma-separated list of days
icon = Column(String, default="fa-dumbbell")
image_url = Column(String, nullable=True)
description = Column(String, nullable=True)
frequency_count = Column(Integer, nullable=True)
selected_days = Column(String, nullable=True)
owner = relationship("User", back_populates="challenges")
trackings = relationship("Tracking", back_populates="challenge", cascade="all, delete-orphan")
+18
View File
@@ -0,0 +1,18 @@
"""Daily Context Model - Stores subjective user inputs like mood, tags, etc."""
from sqlalchemy import Column, Integer, String, Date, ForeignKey, UniqueConstraint
from backend.database import Base
class DailyContext(Base):
__tablename__ = "daily_context"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
date = Column(Date, index=True)
mood_score = Column(Integer) # 1-100 slider
tags = Column(String, nullable=True) # Comma separated tags e.g. "Alcohol,Stress"
notes = Column(String, nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'date', name='uq_daily_context'),
)
+37
View File
@@ -0,0 +1,37 @@
"""DailyHealthMetrics SQLAlchemy model."""
from sqlalchemy import Column, Integer, Date, Float, ForeignKey, UniqueConstraint, String
from sqlalchemy.orm import relationship
from backend.database import Base
class DailyHealthMetrics(Base):
"""Daily health metrics model for tracking health data."""
__tablename__ = "daily_health_metrics"
__table_args__ = (
UniqueConstraint('user_id', 'date', name='uq_user_date'),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
date = Column(Date, index=True)
step_count = Column(Integer, default=0)
distance_meters = Column(Float, default=0.0)
calories_burned = Column(Float, default=0.0)
sleep_duration_minutes = Column(Integer, default=0)
deep_sleep_minutes = Column(Integer, default=0)
light_sleep_minutes = Column(Integer, default=0)
rem_sleep_minutes = Column(Integer, default=0)
awake_duration_minutes = Column(Integer, default=0)
avg_heart_rate = Column(Integer, nullable=True)
min_heart_rate = Column(Integer, nullable=True)
max_heart_rate = Column(Integer, nullable=True)
avg_spo2 = Column(Float, nullable=True)
pai_score = Column(Float, default=0.0)
weight = Column(Float, nullable=True)
resting_heart_rate = Column(Integer, nullable=True)
hrv = Column(Float, nullable=True)
# Data provenance tracking
data_source = Column(String(50), default="health_connect") # "health_connect", "manual"
user = relationship("User", back_populates="health_metrics")
+181
View File
@@ -0,0 +1,181 @@
"""
Health Connect Data Models - Storage for data pushed from Android companion app.
These models store health data received from the Health Connect Android app,
enabling the HealthConnectProvider to serve data to the API layer.
"""
from sqlalchemy import Column, Integer, String, Float, Date, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from backend.database import Base
import datetime
class HealthConnectSyncLog(Base):
"""
Tracks sync operations from the Android companion app.
Used for debugging and monitoring data flow.
"""
__tablename__ = "health_connect_sync_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
device_id = Column(String(100)) # Android device identifier
sync_timestamp = Column(DateTime, default=datetime.datetime.utcnow)
records_pushed = Column(Integer, default=0)
status = Column(String(50)) # "success", "partial", "error"
error_message = Column(Text, nullable=True)
user = relationship("User")
class HealthConnectDailyMetrics(Base):
"""
Daily health metrics received from Health Connect.
Mirrors DailyHealthMetrics but with Health Connect-specific metadata.
"""
__tablename__ = "health_connect_daily_metrics"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
date = Column(Date, index=True)
# Data source identification
data_source = Column(String(200), nullable=True) # Package name of originating app (e.g., com.huami.watch.hmwatchmanager)
data_source_name = Column(String(100), nullable=True) # Friendly name (e.g., "Zepp")
# Activity metrics
step_count = Column(Integer, default=0)
distance_meters = Column(Float, default=0.0)
calories_burned = Column(Float, default=0.0) # ActiveCaloriesBurnedRecord
total_calories = Column(Float, default=0.0) # TotalCaloriesBurnedRecord
# Sleep metrics (from SleepSessionRecord)
sleep_duration_minutes = Column(Integer, default=0)
deep_sleep_minutes = Column(Integer, default=0)
light_sleep_minutes = Column(Integer, default=0)
rem_sleep_minutes = Column(Integer, default=0)
awake_duration_minutes = Column(Integer, default=0)
# Heart rate metrics (from HeartRateRecord)
avg_heart_rate = Column(Integer, nullable=True)
min_heart_rate = Column(Integer, nullable=True)
max_heart_rate = Column(Integer, nullable=True)
resting_heart_rate = Column(Integer, nullable=True)
# SpO2 (from OxygenSaturationRecord)
avg_spo2 = Column(Float, nullable=True)
min_spo2 = Column(Float, nullable=True)
max_spo2 = Column(Float, nullable=True)
# Body metrics
weight = Column(Float, nullable=True) # WeightRecord
# Metadata
last_synced = Column(DateTime, default=datetime.datetime.utcnow)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
user = relationship("User")
class HealthConnectExerciseSession(Base):
"""
Exercise sessions from Health Connect ExerciseSessionRecord.
"""
__tablename__ = "health_connect_exercise_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
# Session timing
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
# Exercise type (Health Connect ExerciseType enum)
exercise_type = Column(Integer) # Health Connect type ID
exercise_type_name = Column(String(100)) # Human-readable name
# Metrics
duration_seconds = Column(Integer, default=0)
distance_meters = Column(Float, nullable=True)
calories_burned = Column(Float, nullable=True)
# Heart rate during exercise
avg_heart_rate = Column(Integer, nullable=True)
max_heart_rate = Column(Integer, nullable=True)
min_heart_rate = Column(Integer, nullable=True)
# Additional metrics
steps = Column(Integer, nullable=True)
elevation_gained = Column(Float, nullable=True)
# Metadata
notes = Column(Text, nullable=True)
last_synced = Column(DateTime, default=datetime.datetime.utcnow)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
user = relationship("User")
# Health Connect Exercise Type mapping (subset)
HEALTH_CONNECT_EXERCISE_TYPES = {
0: "Unknown",
2: "Badminton",
4: "Baseball",
5: "Basketball",
8: "Biking",
9: "Biking Stationary",
10: "Boot Camp",
11: "Boxing",
13: "Calisthenics",
14: "Cricket",
16: "Dancing",
25: "Elliptical",
26: "Exercise Class",
27: "Fencing",
28: "Football American",
29: "Football Australian",
31: "Frisbee Disc",
32: "Golf",
33: "Guided Breathing",
34: "Gymnastics",
35: "Handball",
36: "High Intensity Interval Training",
37: "Hiking",
38: "Ice Hockey",
39: "Ice Skating",
44: "Martial Arts",
46: "Paddling",
48: "Pilates",
50: "Racquetball",
51: "Rock Climbing",
52: "Roller Hockey",
53: "Rowing",
54: "Rowing Machine",
55: "Rugby",
56: "Running",
57: "Running Treadmill",
58: "Sailing",
59: "Scuba Diving",
60: "Skating",
61: "Skiing",
62: "Snowboarding",
63: "Snowshoeing",
64: "Soccer",
65: "Softball",
67: "Squash",
68: "Stair Climbing",
69: "Stair Climbing Machine",
70: "Strength Training",
71: "Stretching",
72: "Surfing",
73: "Swimming Open Water",
74: "Swimming Pool",
75: "Table Tennis",
76: "Tennis",
78: "Volleyball",
79: "Walking",
80: "Water Polo",
81: "Weightlifting",
82: "Wheelchair",
84: "Yoga",
}
+461
View File
@@ -0,0 +1,461 @@
"""
Health Connect Raw Data Models - Storage for raw health records from Health Connect.
These models store individual health records (not aggregated) received from
the Android companion app, enabling accurate aggregation on-demand.
"""
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, UniqueConstraint, Index
from sqlalchemy.orm import relationship
from backend.database import Base
import datetime
# ==================== STEPS ====================
class HCRawSteps(Base):
"""Individual step records from Health Connect StepsRecord"""
__tablename__ = "hc_raw_steps"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
# Record identification (from Health Connect metadata)
record_id = Column(String(100), index=True) # metadata.id - unique per source
data_source = Column(String(200)) # Package name (e.g., com.huami.watch.hmwatchmanager)
data_source_name = Column(String(100)) # Friendly name (e.g., "Zepp")
# Timestamps
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
# Data
count = Column(Integer)
# Sync tracking
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
# Unique constraint: same user + record_id + source = unique record
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_steps_record'),
Index('ix_steps_user_date', 'user_id', 'start_time'),
)
# ==================== HEART RATE ====================
class HCRawHeartRate(Base):
"""Heart rate record containers from Health Connect HeartRateRecord"""
__tablename__ = "hc_raw_heart_rate"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
# Relationship to samples
samples = relationship("HCRawHeartRateSample", back_populates="record", cascade="all, delete-orphan")
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_hr_record'),
Index('ix_hr_user_date', 'user_id', 'start_time'),
)
class HCRawHeartRateSample(Base):
"""Individual heart rate samples within a HeartRateRecord"""
__tablename__ = "hc_raw_heart_rate_samples"
id = Column(Integer, primary_key=True, index=True)
record_id = Column(Integer, ForeignKey("hc_raw_heart_rate.id", ondelete="CASCADE"), index=True)
time = Column(DateTime, index=True)
bpm = Column(Integer)
record = relationship("HCRawHeartRate", back_populates="samples")
# ==================== RESTING HEART RATE ====================
class HCRawRestingHeartRate(Base):
"""Resting heart rate records from Health Connect RestingHeartRateRecord"""
__tablename__ = "hc_raw_resting_hr"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
time = Column(DateTime, index=True)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
bpm = Column(Integer)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_resting_hr_record'),
Index('ix_resting_hr_user_date', 'user_id', 'time'),
)
# ==================== SLEEP ====================
class HCRawSleepSession(Base):
"""Sleep session records from Health Connect SleepSessionRecord"""
__tablename__ = "hc_raw_sleep_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
title = Column(String(200), nullable=True)
notes = Column(Text, nullable=True)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
# Relationship to stages
stages = relationship("HCRawSleepStage", back_populates="session", cascade="all, delete-orphan")
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_sleep_record'),
Index('ix_sleep_user_date', 'user_id', 'start_time'),
)
class HCRawSleepStage(Base):
"""Sleep stages within a sleep session"""
__tablename__ = "hc_raw_sleep_stages"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(Integer, ForeignKey("hc_raw_sleep_sessions.id", ondelete="CASCADE"), index=True)
start_time = Column(DateTime)
end_time = Column(DateTime)
stage_type = Column(Integer) # Health Connect stage type enum
stage_name = Column(String(50)) # Human readable: "awake", "light", "deep", "rem", etc.
session = relationship("HCRawSleepSession", back_populates="stages")
# Sleep stage type constants (from Health Connect)
SLEEP_STAGE_TYPES = {
0: "unknown",
1: "awake",
2: "sleeping",
3: "out_of_bed",
4: "light",
5: "deep",
6: "rem",
7: "awake_in_bed",
}
# ==================== DISTANCE ====================
class HCRawDistance(Base):
"""Distance records from Health Connect DistanceRecord"""
__tablename__ = "hc_raw_distance"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
distance_meters = Column(Float)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_distance_record'),
Index('ix_distance_user_date', 'user_id', 'start_time'),
)
# ==================== CALORIES ====================
class HCRawCalories(Base):
"""Calories records from Health Connect (ActiveCaloriesBurned or TotalCaloriesBurned)"""
__tablename__ = "hc_raw_calories"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
calories = Column(Float)
record_type = Column(String(20)) # "active" or "total"
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_calories_record'),
Index('ix_calories_user_date', 'user_id', 'start_time'),
)
# ==================== OXYGEN SATURATION (SpO2) ====================
class HCRawOxygenSaturation(Base):
"""Oxygen saturation records from Health Connect OxygenSaturationRecord"""
__tablename__ = "hc_raw_spo2"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
time = Column(DateTime, index=True)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
percentage = Column(Float)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_spo2_record'),
Index('ix_spo2_user_date', 'user_id', 'time'),
)
# ==================== WEIGHT ====================
class HCRawWeight(Base):
"""Weight records from Health Connect WeightRecord"""
__tablename__ = "hc_raw_weight"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
time = Column(DateTime, index=True)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
weight_kg = Column(Float)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_weight_record'),
Index('ix_weight_user_date', 'user_id', 'time'),
)
# ==================== HEIGHT ====================
class HCRawHeight(Base):
"""Height records from Health Connect HeightRecord"""
__tablename__ = "hc_raw_height"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
time = Column(DateTime, index=True)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
height_meters = Column(Float)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_height_record'),
Index('ix_height_user_date', 'user_id', 'time'),
)
# ==================== BODY FAT ====================
class HCRawBodyFat(Base):
"""Body fat records from Health Connect BodyFatRecord"""
__tablename__ = "hc_raw_body_fat"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
time = Column(DateTime, index=True)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
percentage = Column(Float)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_body_fat_record'),
Index('ix_body_fat_user_date', 'user_id', 'time'),
)
# ==================== EXERCISE SESSIONS ====================
class HCRawExerciseSession(Base):
"""Exercise session records from Health Connect ExerciseSessionRecord"""
__tablename__ = "hc_raw_exercise_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
# Exercise details
exercise_type = Column(Integer) # Health Connect ExerciseType enum
exercise_type_name = Column(String(100))
title = Column(String(200), nullable=True)
notes = Column(Text, nullable=True)
# Associated metrics (if available in the session)
distance_meters = Column(Float, nullable=True)
calories = Column(Float, nullable=True)
# Heart rate during exercise
avg_heart_rate = Column(Integer, nullable=True)
max_heart_rate = Column(Integer, nullable=True)
min_heart_rate = Column(Integer, nullable=True)
# Additional
steps = Column(Integer, nullable=True)
elevation_gained_meters = Column(Float, nullable=True)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_exercise_record'),
Index('ix_exercise_user_date', 'user_id', 'start_time'),
)
# ==================== NUTRITION ====================
class HCRawNutrition(Base):
"""Nutrition records from Health Connect NutritionRecord"""
__tablename__ = "hc_raw_nutrition"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
# Basic nutrition info
name = Column(String(200), nullable=True)
meal_type = Column(Integer, nullable=True) # 0=unknown, 1=breakfast, 2=lunch, 3=dinner, 4=snack
# Macros
calories = Column(Float, nullable=True)
protein_grams = Column(Float, nullable=True)
carbohydrates_grams = Column(Float, nullable=True)
fat_grams = Column(Float, nullable=True)
fiber_grams = Column(Float, nullable=True)
sugar_grams = Column(Float, nullable=True)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_nutrition_record'),
Index('ix_nutrition_user_date', 'user_id', 'start_time'),
)
# ==================== HYDRATION ====================
class HCRawHydration(Base):
"""Hydration records from Health Connect HydrationRecord"""
__tablename__ = "hc_raw_hydration"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
volume_liters = Column(Float)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_hydration_record'),
Index('ix_hydration_user_date', 'user_id', 'start_time'),
)
# ==================== HEART RATE VARIABILITY ====================
class HCRawHeartRateVariabilityRmssd(Base):
"""HRV records from Health Connect HeartRateVariabilityRmssdRecord"""
__tablename__ = "hc_raw_hrv_rmssd"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
record_id = Column(String(100), index=True)
data_source = Column(String(200))
data_source_name = Column(String(100))
time = Column(DateTime, index=True)
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
rmssd = Column(Float)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
__table_args__ = (
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_hrv_rmssd_record'),
Index('ix_hrv_rmssd_user_date', 'user_id', 'time'),
)
+18
View File
@@ -0,0 +1,18 @@
"""Tracking SQLAlchemy model."""
from sqlalchemy import Column, Integer, Date, Boolean, Text, ForeignKey
from sqlalchemy.orm import relationship
from backend.database import Base
class Tracking(Base):
"""Tracking model for recording challenge progress."""
__tablename__ = "tracking"
id = Column(Integer, primary_key=True, index=True)
challenge_id = Column(Integer, ForeignKey("challenges.id"))
date = Column(Date)
reps = Column(Integer, default=0)
completed = Column(Boolean, default=False)
notes = Column(Text, nullable=True)
challenge = relationship("Challenge", back_populates="trackings")
+17
View File
@@ -0,0 +1,17 @@
"""User SQLAlchemy model."""
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from backend.database import Base
class User(Base):
"""User model for authentication and ownership."""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
hashed_password = Column(String)
challenges = relationship("Challenge", back_populates="owner")
health_metrics = relationship("DailyHealthMetrics", back_populates="user")
workouts = relationship("WorkoutSession", back_populates="user")
+48
View File
@@ -0,0 +1,48 @@
"""WorkoutSession SQLAlchemy model."""
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from backend.database import Base
# Zepp activity type mapping
ZEPP_ACTIVITY_TYPES = {
1: "Running",
6: "Walking",
8: "Cycling",
10: "Elliptical",
16: "Free Training",
23: "HIIT",
52: "Stretching",
60: "Yoga",
}
class WorkoutSession(Base):
"""Workout data model."""
__tablename__ = "workout_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
activity_type = Column(String) # Human readable name
activity_type_id = Column(Integer, nullable=True) # Original Zepp type ID
duration_seconds = Column(Integer, default=0)
distance_meters = Column(Float, default=0.0)
calories = Column(Float, default=0.0)
avg_pace = Column(Float, nullable=True) # seconds per meter
max_pace = Column(Float, nullable=True)
min_pace = Column(Float, nullable=True)
avg_hr = Column(Integer, nullable=True)
max_hr = Column(Integer, nullable=True)
min_hr = Column(Integer, nullable=True)
notes = Column(String, nullable=True)
# Data provenance tracking
data_source = Column(String(50), default="health_connect") # "health_connect", "manual"
user = relationship("User", back_populates="workouts")
@staticmethod
def get_activity_name(type_id: int) -> str:
return ZEPP_ACTIVITY_TYPES.get(type_id, f"Activity {type_id}")
+1
View File
@@ -0,0 +1 @@
"""Repositories module for data access layer."""
+49
View File
@@ -0,0 +1,49 @@
"""Base repository for common database operations."""
from typing import Generic, TypeVar, Type, List, Optional
from sqlalchemy.orm import Session
ModelType = TypeVar("ModelType")
class BaseRepository(Generic[ModelType]):
"""Base repository with common CRUD operations."""
def __init__(self, model: Type[ModelType], db: Session):
"""Initialize repository with model and database session."""
self.model = model
self.db = db
def get_by_id(self, id: int) -> Optional[ModelType]:
"""Get a single record by ID."""
return self.db.query(self.model).filter(self.model.id == id).first()
def get_all(self) -> List[ModelType]:
"""Get all records."""
return self.db.query(self.model).all()
def create(self, **kwargs) -> ModelType:
"""Create a new record."""
db_obj = self.model(**kwargs)
self.db.add(db_obj)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def update(self, id: int, **kwargs) -> Optional[ModelType]:
"""Update a record by ID."""
db_obj = self.get_by_id(id)
if db_obj:
for key, value in kwargs.items():
setattr(db_obj, key, value)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def delete(self, id: int) -> bool:
"""Delete a record by ID."""
db_obj = self.get_by_id(id)
if db_obj:
self.db.delete(db_obj)
self.db.commit()
return True
return False
@@ -0,0 +1,33 @@
"""Challenge repository for data access operations."""
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from backend.models.challenge import Challenge
from backend.repositories.base import BaseRepository
class ChallengeRepository(BaseRepository[Challenge]):
"""Repository for Challenge model operations."""
def __init__(self, db: Session):
"""Initialize challenge repository."""
super().__init__(Challenge, db)
def get_by_user_id(self, user_id: int) -> List[Challenge]:
"""Get all challenges for a user ordered by display_order."""
return self.db.query(Challenge).filter(
Challenge.user_id == user_id
).order_by(Challenge.display_order.asc()).all()
def get_by_user_and_id(self, user_id: int, challenge_id: int) -> Optional[Challenge]:
"""Get a challenge by user ID and challenge ID."""
return self.db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == user_id
).first()
def get_max_display_order(self, user_id: int) -> Optional[int]:
"""Get the maximum display_order for a user."""
return self.db.query(func.max(Challenge.display_order)).filter(
Challenge.user_id == user_id
).scalar()
+17
View File
@@ -0,0 +1,17 @@
"""User repository for data access operations."""
from typing import Optional
from sqlalchemy.orm import Session
from backend.models.user import User
from backend.repositories.base import BaseRepository
class UserRepository(BaseRepository[User]):
"""Repository for User model operations."""
def __init__(self, db: Session):
"""Initialize user repository."""
super().__init__(User, db)
def get_by_username(self, username: str) -> Optional[User]:
"""Get a user by username."""
return self.db.query(User).filter(User.username == username).first()
+16
View File
@@ -0,0 +1,16 @@
"""Pydantic schemas module."""
from backend.schemas.user import UserCreate, UserResponse
from backend.schemas.challenge import ChallengeBase, ChallengeCreate, ChallengeResponse
from backend.schemas.tracking import TrackingBase, TrackingCreate, TrackingResponse
from backend.schemas.daily_health_metrics import DailyHealthMetricsBase, DailyHealthMetricsResponse
from backend.schemas.workout_session import WorkoutSessionBase, WorkoutSessionCreate, WorkoutSessionResponse
from backend.schemas.token import Token
__all__ = [
"UserCreate", "UserResponse",
"ChallengeBase", "ChallengeCreate", "ChallengeResponse",
"TrackingBase", "TrackingCreate", "TrackingResponse",
"DailyHealthMetricsBase", "DailyHealthMetricsResponse",
"WorkoutSessionBase", "WorkoutSessionCreate", "WorkoutSessionResponse",
"Token"
]
+38
View File
@@ -0,0 +1,38 @@
"""Challenge Pydantic schemas."""
from pydantic import BaseModel
from typing import Optional, List
from datetime import date
from backend.schemas.tracking import TrackingResponse
from backend.models.tracking import Tracking
class ChallengeBase(BaseModel):
"""Base schema for challenge."""
name: str
period_type: str
daily_target: int
start_date: date
end_date: Optional[date] = None
unit_type: Optional[str] = "reps"
rest_days: Optional[str] = None
icon: Optional[str] = "fa-dumbbell"
image_url: Optional[str] = None
description: Optional[str] = None
frequency_count: Optional[int] = None
selected_days: Optional[str] = None
class ChallengeCreate(ChallengeBase):
"""Schema for creating a challenge."""
pass
class ChallengeResponse(ChallengeBase):
"""Schema for challenge response."""
id: int
created_at: date
display_order: int = 0
trackings: List[TrackingResponse] = []
class Config:
from_attributes = True
+34
View File
@@ -0,0 +1,34 @@
"""DailyHealthMetrics Pydantic schemas."""
from pydantic import BaseModel
from typing import Optional
from datetime import date
class DailyHealthMetricsBase(BaseModel):
"""Base schema for daily health metrics."""
date: date
step_count: Optional[int] = 0
distance_meters: Optional[float] = 0.0
calories_burned: Optional[float] = 0.0
sleep_duration_minutes: Optional[int] = 0
deep_sleep_minutes: Optional[int] = 0
light_sleep_minutes: Optional[int] = 0
rem_sleep_minutes: Optional[int] = 0
awake_duration_minutes: Optional[int] = 0
avg_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
avg_spo2: Optional[float] = None
pai_score: Optional[float] = 0.0
weight: Optional[float] = None
resting_heart_rate: Optional[int] = None
hrv: Optional[float] = None
class DailyHealthMetricsResponse(DailyHealthMetricsBase):
"""Schema for daily health metrics response."""
id: int
user_id: int
class Config:
from_attributes = True
+9
View File
@@ -0,0 +1,9 @@
"""Token Pydantic schema."""
from pydantic import BaseModel
class Token(BaseModel):
"""Schema for JWT token response."""
access_token: str
refresh_token: str
token_type: str
+26
View File
@@ -0,0 +1,26 @@
"""Tracking Pydantic schemas."""
from pydantic import BaseModel
from typing import Optional
from datetime import date
class TrackingBase(BaseModel):
"""Base schema for tracking."""
date: date
reps: int
completed: bool = False
notes: Optional[str] = None
class TrackingCreate(TrackingBase):
"""Schema for creating tracking."""
challenge_id: int
class TrackingResponse(TrackingBase):
"""Schema for tracking response."""
id: int
challenge_id: int
class Config:
from_attributes = True
+17
View File
@@ -0,0 +1,17 @@
"""User Pydantic schemas."""
from pydantic import BaseModel
class UserCreate(BaseModel):
"""Schema for user registration."""
username: str
password: str
class UserResponse(BaseModel):
"""Schema for user response."""
id: int
username: str
class Config:
from_attributes = True
+36
View File
@@ -0,0 +1,36 @@
"""WorkoutSession Pydantic schemas."""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class WorkoutSessionBase(BaseModel):
"""Base schema for workout session."""
start_time: datetime
end_time: Optional[datetime] = None
activity_type: str
activity_type_id: Optional[int] = None
duration_seconds: int = 0
distance_meters: float = 0.0
calories: float = 0.0
avg_pace: Optional[float] = None
max_pace: Optional[float] = None
min_pace: Optional[float] = None
avg_hr: Optional[int] = None
max_hr: Optional[int] = None
min_hr: Optional[int] = None
notes: Optional[str] = None
class WorkoutSessionCreate(WorkoutSessionBase):
"""Schema for creating a workout session."""
pass
class WorkoutSessionResponse(WorkoutSessionBase):
"""Schema for workout session response."""
id: int
user_id: int
class Config:
from_attributes = True
+1
View File
@@ -0,0 +1 @@
"""Services module for business logic."""
+497
View File
@@ -0,0 +1,497 @@
"""
Aggregation Service - Computes aggregated metrics from raw Health Connect data.
This service calculates daily, weekly, and monthly aggregations on-demand
from the raw health records stored in the database.
"""
from sqlalchemy.orm import Session
from sqlalchemy import func, and_
from datetime import datetime, date, timedelta
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
from backend.models.health_connect_raw import (
HCRawSteps,
HCRawHeartRate,
HCRawHeartRateSample,
HCRawRestingHeartRate,
HCRawSleepSession,
HCRawSleepStage,
HCRawDistance,
HCRawCalories,
HCRawOxygenSaturation,
HCRawWeight,
HCRawExerciseSession,
)
@dataclass
class DailyMetrics:
"""Aggregated daily health metrics"""
date: date
step_count: int = 0
distance_meters: float = 0.0
calories_burned: float = 0.0
active_calories: float = 0.0
# Sleep
sleep_duration_minutes: int = 0
deep_sleep_minutes: int = 0
light_sleep_minutes: int = 0
rem_sleep_minutes: int = 0
awake_minutes: int = 0
# Heart rate
avg_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
resting_heart_rate: Optional[int] = None
# SpO2
avg_spo2: Optional[float] = None
min_spo2: Optional[float] = None
max_spo2: Optional[float] = None
# Body
weight: Optional[float] = None
# Counts
exercise_count: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
"date": self.date.isoformat(),
"step_count": self.step_count,
"distance_meters": self.distance_meters,
"calories_burned": self.calories_burned,
"active_calories": self.active_calories,
"sleep_duration_minutes": self.sleep_duration_minutes,
"deep_sleep_minutes": self.deep_sleep_minutes,
"light_sleep_minutes": self.light_sleep_minutes,
"rem_sleep_minutes": self.rem_sleep_minutes,
"awake_minutes": self.awake_minutes,
"avg_heart_rate": self.avg_heart_rate,
"min_heart_rate": self.min_heart_rate,
"max_heart_rate": self.max_heart_rate,
"resting_heart_rate": self.resting_heart_rate,
"avg_spo2": self.avg_spo2,
"min_spo2": self.min_spo2,
"max_spo2": self.max_spo2,
"weight": self.weight,
"exercise_count": self.exercise_count,
}
class AggregationService:
"""
Service for computing aggregated metrics from raw Health Connect data.
All methods take a user_id and return computed metrics based on the
raw records stored in the database.
"""
def __init__(self, db: Session):
self.db = db
# ==================== DAILY AGGREGATIONS ====================
def get_daily_steps(self, user_id: int, target_date: date) -> int:
"""Sum all step records for a given day."""
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
result = self.db.query(func.sum(HCRawSteps.count)).filter(
HCRawSteps.user_id == user_id,
HCRawSteps.start_time >= start_of_day,
HCRawSteps.start_time < end_of_day
).scalar()
return result or 0
def get_daily_distance(self, user_id: int, target_date: date) -> float:
"""Sum all distance records for a given day."""
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
result = self.db.query(func.sum(HCRawDistance.distance_meters)).filter(
HCRawDistance.user_id == user_id,
HCRawDistance.start_time >= start_of_day,
HCRawDistance.start_time < end_of_day
).scalar()
return result or 0.0
def get_daily_calories(self, user_id: int, target_date: date) -> Dict[str, float]:
"""Get calories burned for a given day (active and total)."""
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
# Active calories
active = self.db.query(func.sum(HCRawCalories.calories)).filter(
HCRawCalories.user_id == user_id,
HCRawCalories.start_time >= start_of_day,
HCRawCalories.start_time < end_of_day,
HCRawCalories.record_type == "active"
).scalar() or 0.0
# Total calories
total = self.db.query(func.sum(HCRawCalories.calories)).filter(
HCRawCalories.user_id == user_id,
HCRawCalories.start_time >= start_of_day,
HCRawCalories.start_time < end_of_day,
HCRawCalories.record_type == "total"
).scalar() or 0.0
# If no total, use active as total
if total == 0 and active > 0:
total = active
return {"active": active, "total": total}
def get_daily_heart_rate_stats(self, user_id: int, target_date: date) -> Dict[str, Optional[int]]:
"""Calculate min, max, avg heart rate for a day from all samples."""
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
# Get all heart rate records for the day
hr_records = self.db.query(HCRawHeartRate).filter(
HCRawHeartRate.user_id == user_id,
HCRawHeartRate.start_time >= start_of_day,
HCRawHeartRate.start_time < end_of_day
).all()
if not hr_records:
return {"avg": None, "min": None, "max": None}
# Collect all samples
all_bpm = []
for record in hr_records:
for sample in record.samples:
if sample.bpm and sample.bpm > 0:
all_bpm.append(sample.bpm)
if not all_bpm:
return {"avg": None, "min": None, "max": None}
return {
"avg": int(sum(all_bpm) / len(all_bpm)),
"min": min(all_bpm),
"max": max(all_bpm)
}
def get_daily_resting_heart_rate(self, user_id: int, target_date: date) -> Optional[int]:
"""Get the resting heart rate for a day (last recorded value)."""
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
record = self.db.query(HCRawRestingHeartRate).filter(
HCRawRestingHeartRate.user_id == user_id,
HCRawRestingHeartRate.time >= start_of_day,
HCRawRestingHeartRate.time < end_of_day
).order_by(HCRawRestingHeartRate.time.desc()).first()
return record.bpm if record else None
def get_daily_sleep_stats(self, user_id: int, target_date: date) -> Dict[str, int]:
"""
Get sleep statistics for a day.
Sleep is attributed to the day you wake up (e.g., sleep from 11pm Jan 7
to 7am Jan 8 is counted for Jan 8).
"""
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
# Find sleep sessions that ended on this day
sessions = self.db.query(HCRawSleepSession).filter(
HCRawSleepSession.user_id == user_id,
HCRawSleepSession.end_time >= start_of_day,
HCRawSleepSession.end_time < end_of_day
).all()
total_minutes = 0
deep_minutes = 0
light_minutes = 0
rem_minutes = 0
awake_minutes = 0
for session in sessions:
# Total sleep duration
if session.start_time and session.end_time:
duration = (session.end_time - session.start_time).total_seconds() / 60
total_minutes += int(duration)
# Stage breakdown
for stage in session.stages:
if stage.start_time and stage.end_time:
stage_duration = (stage.end_time - stage.start_time).total_seconds() / 60
stage_minutes = int(stage_duration)
if stage.stage_name in ("deep", "STAGE_TYPE_DEEP"):
deep_minutes += stage_minutes
elif stage.stage_name in ("light", "STAGE_TYPE_LIGHT"):
light_minutes += stage_minutes
elif stage.stage_name in ("rem", "STAGE_TYPE_REM"):
rem_minutes += stage_minutes
elif stage.stage_name in ("awake", "awake_in_bed", "STAGE_TYPE_AWAKE"):
awake_minutes += stage_minutes
return {
"total": total_minutes,
"deep": deep_minutes,
"light": light_minutes,
"rem": rem_minutes,
"awake": awake_minutes
}
def get_daily_spo2_stats(self, user_id: int, target_date: date) -> Dict[str, Optional[float]]:
"""Get SpO2 statistics for a day."""
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
records = self.db.query(HCRawOxygenSaturation).filter(
HCRawOxygenSaturation.user_id == user_id,
HCRawOxygenSaturation.time >= start_of_day,
HCRawOxygenSaturation.time < end_of_day
).all()
if not records:
return {"avg": None, "min": None, "max": None}
values = [r.percentage for r in records if r.percentage]
if not values:
return {"avg": None, "min": None, "max": None}
return {
"avg": sum(values) / len(values),
"min": min(values),
"max": max(values)
}
def get_daily_weight(self, user_id: int, target_date: date) -> Optional[float]:
"""Get the latest weight recorded on a day."""
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
record = self.db.query(HCRawWeight).filter(
HCRawWeight.user_id == user_id,
HCRawWeight.time >= start_of_day,
HCRawWeight.time < end_of_day
).order_by(HCRawWeight.time.desc()).first()
return record.weight_kg if record else None
def get_daily_exercise_count(self, user_id: int, target_date: date) -> int:
"""Count exercise sessions for a day."""
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
return self.db.query(func.count(HCRawExerciseSession.id)).filter(
HCRawExerciseSession.user_id == user_id,
HCRawExerciseSession.start_time >= start_of_day,
HCRawExerciseSession.start_time < end_of_day
).scalar() or 0
# ==================== COMBINED DAILY METRICS ====================
def get_daily_metrics(self, user_id: int, target_date: date) -> DailyMetrics:
"""Get all aggregated metrics for a single day."""
metrics = DailyMetrics(date=target_date)
# Steps and Activity
metrics.step_count = self.get_daily_steps(user_id, target_date)
metrics.distance_meters = self.get_daily_distance(user_id, target_date)
calories = self.get_daily_calories(user_id, target_date)
metrics.calories_burned = calories["total"]
metrics.active_calories = calories["active"]
# Heart Rate
hr_stats = self.get_daily_heart_rate_stats(user_id, target_date)
metrics.avg_heart_rate = hr_stats["avg"]
metrics.min_heart_rate = hr_stats["min"]
metrics.max_heart_rate = hr_stats["max"]
metrics.resting_heart_rate = self.get_daily_resting_heart_rate(user_id, target_date)
# Sleep
sleep_stats = self.get_daily_sleep_stats(user_id, target_date)
metrics.sleep_duration_minutes = sleep_stats["total"]
metrics.deep_sleep_minutes = sleep_stats["deep"]
metrics.light_sleep_minutes = sleep_stats["light"]
metrics.rem_sleep_minutes = sleep_stats["rem"]
metrics.awake_minutes = sleep_stats["awake"]
# SpO2
spo2_stats = self.get_daily_spo2_stats(user_id, target_date)
metrics.avg_spo2 = spo2_stats["avg"]
metrics.min_spo2 = spo2_stats["min"]
metrics.max_spo2 = spo2_stats["max"]
# Body
metrics.weight = self.get_daily_weight(user_id, target_date)
# Exercise
metrics.exercise_count = self.get_daily_exercise_count(user_id, target_date)
return metrics
# ==================== RANGE AGGREGATIONS ====================
def get_metrics_for_range(
self,
user_id: int,
start_date: date,
end_date: date
) -> List[DailyMetrics]:
"""Get daily metrics for a date range."""
metrics = []
current_date = start_date
while current_date <= end_date:
daily = self.get_daily_metrics(user_id, current_date)
metrics.append(daily)
current_date += timedelta(days=1)
return metrics
def get_weekly_summary(self, user_id: int, ref_date: date) -> Dict[str, Any]:
"""Get aggregated summary for a week (7 days ending on ref_date)."""
start_date = ref_date - timedelta(days=6)
daily_metrics = self.get_metrics_for_range(user_id, start_date, ref_date)
# Aggregate
total_steps = sum(m.step_count for m in daily_metrics)
total_distance = sum(m.distance_meters for m in daily_metrics)
total_calories = sum(m.calories_burned for m in daily_metrics)
# Average heart rate (excluding days with no data)
hr_values = [m.avg_heart_rate for m in daily_metrics if m.avg_heart_rate]
avg_hr = int(sum(hr_values) / len(hr_values)) if hr_values else None
# Average sleep
sleep_values = [m.sleep_duration_minutes for m in daily_metrics if m.sleep_duration_minutes > 0]
avg_sleep = int(sum(sleep_values) / len(sleep_values)) if sleep_values else 0
# Latest weight
weights = [m.weight for m in daily_metrics if m.weight]
latest_weight = weights[-1] if weights else None
return {
"period": "week",
"start_date": start_date.isoformat(),
"end_date": ref_date.isoformat(),
"days_with_data": len([m for m in daily_metrics if m.step_count > 0]),
"totals": {
"steps": total_steps,
"distance_meters": total_distance,
"calories_burned": total_calories,
"exercise_count": sum(m.exercise_count for m in daily_metrics),
},
"averages": {
"steps_per_day": total_steps // 7,
"distance_per_day": total_distance / 7,
"calories_per_day": total_calories / 7,
"sleep_minutes": avg_sleep,
"heart_rate": avg_hr,
},
"latest": {
"weight": latest_weight,
},
"daily": [m.to_dict() for m in daily_metrics]
}
def get_monthly_summary(self, user_id: int, ref_date: date) -> Dict[str, Any]:
"""Get aggregated summary for a month (30 days ending on ref_date)."""
start_date = ref_date - timedelta(days=29)
daily_metrics = self.get_metrics_for_range(user_id, start_date, ref_date)
# Aggregate
total_steps = sum(m.step_count for m in daily_metrics)
total_distance = sum(m.distance_meters for m in daily_metrics)
total_calories = sum(m.calories_burned for m in daily_metrics)
days_count = len(daily_metrics)
return {
"period": "month",
"start_date": start_date.isoformat(),
"end_date": ref_date.isoformat(),
"days_with_data": len([m for m in daily_metrics if m.step_count > 0]),
"totals": {
"steps": total_steps,
"distance_meters": total_distance,
"calories_burned": total_calories,
"exercise_count": sum(m.exercise_count for m in daily_metrics),
},
"averages": {
"steps_per_day": total_steps // days_count if days_count > 0 else 0,
"distance_per_day": total_distance / days_count if days_count > 0 else 0,
"calories_per_day": total_calories / days_count if days_count > 0 else 0,
},
}
# ==================== TODAY'S METRICS (CURRENT/LIVE) ====================
def get_today_metrics(self, user_id: int) -> DailyMetrics:
"""
Get today's metrics - useful for dashboard display.
This returns the most up-to-date aggregation for the current day.
"""
return self.get_daily_metrics(user_id, date.today())
# ==================== METRIC HISTORY ====================
def get_metric_history(
self,
user_id: int,
metric_type: str,
start_date: date,
end_date: date
) -> List[Dict[str, Any]]:
"""
Get historical data for a specific metric type.
Args:
metric_type: One of "steps", "heart_rate", "sleep", "weight", "spo2"
"""
history = []
current_date = start_date
while current_date <= end_date:
data_point = {"date": current_date.isoformat()}
if metric_type == "steps":
data_point["value"] = self.get_daily_steps(user_id, current_date)
elif metric_type == "distance":
data_point["value"] = self.get_daily_distance(user_id, current_date)
elif metric_type == "calories":
cals = self.get_daily_calories(user_id, current_date)
data_point["value"] = cals["total"]
data_point["active"] = cals["active"]
elif metric_type == "heart_rate":
hr = self.get_daily_heart_rate_stats(user_id, current_date)
data_point["avg"] = hr["avg"]
data_point["min"] = hr["min"]
data_point["max"] = hr["max"]
data_point["resting"] = self.get_daily_resting_heart_rate(user_id, current_date)
elif metric_type == "sleep":
sleep = self.get_daily_sleep_stats(user_id, current_date)
data_point["total"] = sleep["total"]
data_point["deep"] = sleep["deep"]
data_point["light"] = sleep["light"]
data_point["rem"] = sleep["rem"]
elif metric_type == "weight":
data_point["value"] = self.get_daily_weight(user_id, current_date)
elif metric_type == "spo2":
spo2 = self.get_daily_spo2_stats(user_id, current_date)
data_point["avg"] = spo2["avg"]
data_point["min"] = spo2["min"]
data_point["max"] = spo2["max"]
history.append(data_point)
current_date += timedelta(days=1)
return history
+52
View File
@@ -0,0 +1,52 @@
"""Authentication service for business logic."""
from typing import Optional
from sqlalchemy.orm import Session
from backend.models.user import User
from backend import auth
from backend.schemas.user import UserCreate
class AuthService:
"""Service for authentication operations."""
@staticmethod
def register_user(db: Session, user_data: UserCreate) -> User:
"""Register a new user."""
db_user = db.query(User).filter(
User.username == user_data.username
).first()
if db_user:
raise ValueError("Username already registered")
hashed_password = auth.get_password_hash(user_data.password)
new_user = models.User(
username=user_data.username,
hashed_password=hashed_password
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@staticmethod
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
"""Authenticate a user with username and password."""
user = db.query(User).filter(
User.username == username
).first()
if not user or not auth.verify_password(password, user.hashed_password):
return None
return user
@staticmethod
def create_access_token(username: str) -> str:
"""Create a JWT access token for a user."""
from datetime import timedelta
from backend.core.config import settings
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = auth.create_access_token(
data={"sub": username},
expires_delta=access_token_expires
)
return access_token
+86
View File
@@ -0,0 +1,86 @@
"""Challenge service for business logic."""
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from backend.models.challenge import Challenge
from backend.schemas.challenge import ChallengeCreate
class ChallengeService:
"""Service for challenge operations."""
@staticmethod
def create_challenge(
db: Session,
challenge_data: ChallengeCreate,
user_id: int
) -> Challenge:
"""Create a new challenge for a user."""
# Calculate next display_order for THIS user
max_order = db.query(func.max(Challenge.display_order)).filter(
Challenge.user_id == user_id
).scalar()
new_order = (max_order if max_order is not None else -1) + 1
db_challenge = Challenge(
**challenge_data.dict(),
display_order=new_order,
user_id=user_id
)
db.add(db_challenge)
db.commit()
db.refresh(db_challenge)
return db_challenge
@staticmethod
def get_user_challenges(
db: Session,
user_id: int
) -> List[Challenge]:
"""Get all challenges for a user."""
challenges = db.query(Challenge).filter(
Challenge.user_id == user_id
).order_by(Challenge.display_order.asc()).all()
return challenges
@staticmethod
def reorder_challenges(
db: Session,
ordered_ids: List[int],
user_id: int
) -> None:
"""Reorder challenges for a user."""
for index, challenge_id in enumerate(ordered_ids):
db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == user_id
).update({"display_order": index})
db.commit()
@staticmethod
def delete_challenge(
db: Session,
challenge_id: int,
user_id: int
) -> Optional[Challenge]:
"""Delete a challenge for a user."""
db_challenge = db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == user_id
).first()
if db_challenge:
db.delete(db_challenge)
db.commit()
return db_challenge
@staticmethod
def get_challenge_by_id(
db: Session,
challenge_id: int,
user_id: int
) -> Optional[Challenge]:
"""Get a challenge by ID for a user."""
return db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == user_id
).first()
+26
View File
@@ -0,0 +1,26 @@
"""Health service for business logic."""
from sqlalchemy.orm import Session
from sqlalchemy import text
class HealthService:
"""Service for health check operations."""
@staticmethod
def check_database(db: Session) -> bool:
"""Check if database connection is healthy."""
try:
db.execute(text("SELECT 1"))
return True
except Exception:
return False
@staticmethod
def get_health_status(db: Session) -> dict:
"""Get the health status of the application."""
db_healthy = HealthService.check_database(db)
return {
"status": "healthy" if db_healthy else "unhealthy",
"database": "connected" if db_healthy else "disconnected"
}
+80
View File
@@ -0,0 +1,80 @@
import os
import sys
from sqlalchemy import create_engine, text
from datetime import datetime
import json
# Database connection
DATABASE_URL = "postgresql://habitforge:habitforge@localhost:5432/habitforge"
def check_sync_status():
try:
engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
print("\n=== SYNC STATUS REPORT ===")
# 1. Check Sync Logs
print("\n--- Latest Sync Session ---")
query_logs = text("""
SELECT id, status, sync_timestamp, records_pushed, error_message
FROM health_connect_sync_logs
ORDER BY sync_timestamp DESC
LIMIT 1
""")
result_logs = connection.execute(query_logs).fetchone()
if result_logs:
print(f"Sync ID: {result_logs[0]}")
print(f"Status: {result_logs[1]}")
print(f"Timestamp: {result_logs[2]}")
print(f"Records Pushed: {result_logs[3]}")
if result_logs[4]:
print(f"Error: {result_logs[4]}")
sync_id = result_logs[0]
else:
print("No sync logs found.")
return
# 2. Check Raw Tables
tables = [
"hc_raw_steps",
"hc_raw_heart_rate",
"hc_raw_resting_hr",
"hc_raw_sleep_sessions",
"hc_raw_distance",
"hc_raw_calories",
"hc_raw_weight",
"hc_raw_spo2",
"hc_raw_exercise_sessions",
"hc_raw_nutrition",
"hc_raw_hydration"
]
print("\n--- Raw Data Counts (All Time) ---")
for table in tables:
try:
# Check if table exists first (handling case where some tables might not be created yet)
query_count = text(f"SELECT COUNT(*), MIN(start_time), MAX(start_time) FROM {table}")
result_count = connection.execute(query_count).fetchone()
count = result_count[0]
min_time = result_count[1]
max_time = result_count[2]
print(f"{table}: {count} records (Latest: {max_time})")
except Exception as e:
connection.rollback()
print(f"{table}: Error querying (might not exist or empty) - {str(e).splitlines()[0]}")
# 3. Specific check for the user's main concern: Steps from the latest sync
print("\n--- Steps from Latest Sync ---")
if sync_id:
query_steps_sync = text(f"SELECT COUNT(*) FROM hc_raw_steps WHERE sync_id = :sync_id")
steps_synced_count = connection.execute(query_steps_sync, {"sync_id": sync_id}).scalar()
print(f"Steps added in last sync (ID {sync_id}): {steps_synced_count}")
except Exception as e:
print(f"CRITICAL ERROR: {e}")
if __name__ == "__main__":
check_sync_status()