From df9dcb2f79b63cdcb9fe293e368350154f33dd8a Mon Sep 17 00:00:00 2001 From: sstent Date: Thu, 25 Dec 2025 08:33:01 -0800 Subject: [PATCH] feat: Update spec, fix bugs, improve UI/UX, and clean up code --- FitnessSync/backend/main.py | 58 ++- FitnessSync/backend/src/api/activities.py | 158 ++++++- FitnessSync/backend/src/api/metrics.py | 238 ++++++++--- FitnessSync/backend/src/api/setup.py | 265 +----------- FitnessSync/backend/src/api/status.py | 50 ++- FitnessSync/backend/src/api/sync.py | 79 ++-- .../backend/src/services/garmin/auth.py | 132 +++--- .../backend/src/services/garmin/client.py | 7 +- .../backend/src/services/garmin/data.py | 176 +++----- FitnessSync/backend/src/services/sync_app.py | 386 ++++++------------ FitnessSync/backend/src/utils/helpers.py | 17 +- .../backend/src/utils/logging_config.py | 28 ++ FitnessSync/backend/templates/index.html | 273 ++++--------- .../checklists/requirements.md | 34 ++ .../contracts/api-contract.yaml | 273 +++++++++++++ .../002-fitbit-garmin-sync/data-model.md | 65 +++ .../specs/002-fitbit-garmin-sync/plan.md | 77 ++++ .../002-fitbit-garmin-sync/quickstart.md | 78 ++++ .../specs/002-fitbit-garmin-sync/research.md | 30 ++ .../specs/002-fitbit-garmin-sync/spec.md | 157 +++++++ .../specs/002-fitbit-garmin-sync/tasks.md | 215 ++++++++++ 21 files changed, 1741 insertions(+), 1055 deletions(-) create mode 100644 FitnessSync/backend/src/utils/logging_config.py create mode 100644 FitnessSync/specs/002-fitbit-garmin-sync/checklists/requirements.md create mode 100644 FitnessSync/specs/002-fitbit-garmin-sync/contracts/api-contract.yaml create mode 100644 FitnessSync/specs/002-fitbit-garmin-sync/data-model.md create mode 100644 FitnessSync/specs/002-fitbit-garmin-sync/plan.md create mode 100644 FitnessSync/specs/002-fitbit-garmin-sync/quickstart.md create mode 100644 FitnessSync/specs/002-fitbit-garmin-sync/research.md create mode 100644 FitnessSync/specs/002-fitbit-garmin-sync/spec.md create mode 100644 FitnessSync/specs/002-fitbit-garmin-sync/tasks.md diff --git a/FitnessSync/backend/main.py b/FitnessSync/backend/main.py index c263a10..a4720a4 100644 --- a/FitnessSync/backend/main.py +++ b/FitnessSync/backend/main.py @@ -2,55 +2,47 @@ from fastapi import FastAPI, Request from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from contextlib import asynccontextmanager -from src.services.postgresql_manager import PostgreSQLManager +from src.utils.logging_config import setup_logging from alembic.config import Config from alembic import command import os +import logging -# Create application lifespan to handle startup/shutdown @asynccontextmanager async def lifespan(app: FastAPI): # Startup - # Run database migrations - alembic_cfg = Config("alembic.ini") - database_url = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync") - alembic_cfg.set_main_option("sqlalchemy.url", database_url) - command.upgrade(alembic_cfg, "head") + setup_logging() + logger = logging.getLogger(__name__) + logger.info("--- Application Starting Up ---") - # Initialize database tables - db_manager = PostgreSQLManager(database_url=database_url) - db_manager.init_db() + alembic_cfg = Config("alembic.ini") + database_url = os.getenv("DATABASE_URL") + if database_url: + alembic_cfg.set_main_option("sqlalchemy.url", database_url) + try: + command.upgrade(alembic_cfg, "head") + logger.info("Database migrations checked/applied.") + except Exception as e: + logger.error(f"Error running database migrations: {e}") + else: + logger.warning("DATABASE_URL not set, skipping migrations.") yield - # Shutdown - # Add any cleanup code here if needed + logger.info("--- Application Shutting Down ---") -# Create FastAPI app with lifespan app = FastAPI(lifespan=lifespan) - -# Mount static files app.mount("/static", StaticFiles(directory="static"), name="static") - -# Initialize templates templates = Jinja2Templates(directory="templates") -# Include API routes -from src.api.status import router as status_router -from src.api.sync import router as sync_router -from src.api.setup import router as setup_router -from src.api.logs import router as logs_router -from src.api.metrics import router as metrics_router -from src.api.activities import router as activities_router +from src.api import status, sync, setup, logs, metrics, activities -app.include_router(status_router, prefix="/api") -app.include_router(sync_router, prefix="/api") -app.include_router(setup_router, prefix="/api") -app.include_router(logs_router, prefix="/api") -app.include_router(metrics_router, prefix="/api") -app.include_router(activities_router, prefix="/api") - -from fastapi import Request +app.include_router(status.router, prefix="/api") +app.include_router(sync.router, prefix="/api") +app.include_router(setup.router, prefix="/api") +app.include_router(logs.router, prefix="/api") +app.include_router(metrics.router, prefix="/api") +app.include_router(activities.router, prefix="/api") @app.get("/") async def read_root(request: Request): @@ -58,4 +50,4 @@ async def read_root(request: Request): @app.get("/setup") async def setup_page(request: Request): - return templates.TemplateResponse("setup.html", {"request": request}) \ No newline at end of file + return templates.TemplateResponse("setup.html", {"request": request}) diff --git a/FitnessSync/backend/src/api/activities.py b/FitnessSync/backend/src/api/activities.py index c097659..d12e31c 100644 --- a/FitnessSync/backend/src/api/activities.py +++ b/FitnessSync/backend/src/api/activities.py @@ -1,9 +1,22 @@ -from fastapi import APIRouter, Query, Response +from fastapi import APIRouter, Query, Response, HTTPException, Depends from pydantic import BaseModel from typing import List, Optional, Dict, Any +from sqlalchemy import func +from ..models.activity import Activity +import logging +from ..services.postgresql_manager import PostgreSQLManager +from sqlalchemy.orm import Session +from ..utils.config import config router = APIRouter() +logger = logging.getLogger(__name__) + +def get_db(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + yield session + class ActivityResponse(BaseModel): id: Optional[int] = None garmin_activity_id: Optional[str] = None @@ -19,26 +32,143 @@ class ActivityResponse(BaseModel): @router.get("/activities/list", response_model=List[ActivityResponse]) async def list_activities( limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0) + offset: int = Query(0, ge=0), + db: Session = Depends(get_db) ): - # This would return metadata for all downloaded/available activities - # Implementation will connect with the services layer - return [] + """ + Return metadata for all downloaded/available activities. + """ + try: + logger.info(f"Listing activities with limit={limit}, offset={offset}") + + # Query the database for activities + activities = db.query(Activity).offset(offset).limit(limit).all() + + # Convert SQLAlchemy objects to Pydantic models + activity_responses = [] + for activity in activities: + activity_responses.append( + ActivityResponse( + id=activity.id, + garmin_activity_id=activity.garmin_activity_id, + activity_name=activity.activity_name, + activity_type=activity.activity_type, + start_time=activity.start_time.isoformat() if activity.start_time else None, + duration=activity.duration, + file_type=activity.file_type, + download_status=activity.download_status, + downloaded_at=activity.downloaded_at.isoformat() if activity.downloaded_at else None + ) + ) + + logger.info(f"Returning {len(activity_responses)} activities") + return activity_responses + except Exception as e: + logger.error(f"Error in list_activities: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error listing activities: {str(e)}") @router.get("/activities/query", response_model=List[ActivityResponse]) async def query_activities( activity_type: Optional[str] = Query(None), start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), - download_status: Optional[str] = Query(None) + download_status: Optional[str] = Query(None), + db: Session = Depends(get_db) ): - # This would allow advanced filtering of activities - # Implementation will connect with the services layer - return [] + """ + Allow advanced filtering of activities. + """ + try: + logger.info(f"Querying activities - type: {activity_type}, start: {start_date}, end: {end_date}, status: {download_status}") + + # Start building the query + query = db.query(Activity) + + # Apply filters based on parameters + if activity_type: + query = query.filter(Activity.activity_type == activity_type) + + if start_date: + from datetime import datetime + start_dt = datetime.fromisoformat(start_date) + query = query.filter(Activity.start_time >= start_dt) + + if end_date: + from datetime import datetime + end_dt = datetime.fromisoformat(end_date) + query = query.filter(Activity.start_time <= end_dt) + + if download_status: + query = query.filter(Activity.download_status == download_status) + + # Execute the query + activities = query.all() + + # Convert SQLAlchemy objects to Pydantic models + activity_responses = [] + for activity in activities: + activity_responses.append( + ActivityResponse( + id=activity.id, + garmin_activity_id=activity.garmin_activity_id, + activity_name=activity.activity_name, + activity_type=activity.activity_type, + start_time=activity.start_time.isoformat() if activity.start_time else None, + duration=activity.duration, + file_type=activity.file_type, + download_status=activity.download_status, + downloaded_at=activity.downloaded_at.isoformat() if activity.downloaded_at else None + ) + ) + + logger.info(f"Returning {len(activity_responses)} filtered activities") + return activity_responses + except Exception as e: + logger.error(f"Error in query_activities: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error querying activities: {str(e)}") @router.get("/activities/download/{activity_id}") -async def download_activity(activity_id: str): - # This would serve the stored activity file from the database - # Implementation will connect with the services layer - # It should return the file content with appropriate content-type - return Response(content=b"sample_content", media_type="application/octet-stream", headers={"Content-Disposition": f"attachment; filename=activity_{activity_id}.tcx"}) \ No newline at end of file +async def download_activity(activity_id: str, db: Session = Depends(get_db)): + """ + Serve the stored activity file from the database. + """ + try: + logger.info(f"Downloading activity with ID: {activity_id}") + + # Find the activity in the database + activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first() + + if not activity: + raise HTTPException(status_code=404, detail=f"Activity with ID {activity_id} not found") + + if not activity.file_content: + raise HTTPException(status_code=404, detail=f"No file content available for activity {activity_id}") + + if activity.download_status != 'downloaded': + raise HTTPException(status_code=400, detail=f"File for activity {activity_id} is not ready for download (status: {activity.download_status})") + + # Determine the appropriate content type based on the file type + content_type_map = { + 'tcx': 'application/vnd.garmin.tcx+xml', + 'gpx': 'application/gpx+xml', + 'fit': 'application/octet-stream' # FIT files are binary + } + + content_type = content_type_map.get(activity.file_type, 'application/octet-stream') + filename = f"activity_{activity_id}.{activity.file_type}" + + logger.info(f"Returning file for activity {activity_id} with content type {content_type}") + return Response( + content=activity.file_content, + media_type=content_type, + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(activity.file_content)) + } + ) + except HTTPException: + # Re-raise HTTP exceptions as-is + raise + except Exception as e: + logger.error(f"Error in download_activity for ID {activity_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error downloading activity: {str(e)}") \ No newline at end of file diff --git a/FitnessSync/backend/src/api/metrics.py b/FitnessSync/backend/src/api/metrics.py index 6e43609..856e735 100644 --- a/FitnessSync/backend/src/api/metrics.py +++ b/FitnessSync/backend/src/api/metrics.py @@ -1,9 +1,22 @@ -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, HTTPException, Depends from pydantic import BaseModel from typing import List, Optional, Dict, Any +from sqlalchemy import func +from ..models.health_metric import HealthMetric +import logging +from ..services.postgresql_manager import PostgreSQLManager +from sqlalchemy.orm import Session +from ..utils.config import config router = APIRouter() +logger = logging.getLogger(__name__) + +def get_db(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + yield session + class HealthMetricResponse(BaseModel): id: int metric_type: str @@ -28,71 +41,188 @@ class HealthDataSummary(BaseModel): total_sleep_hours: Optional[float] = 0.0 avg_calories: Optional[float] = 0.0 -class ActivityResponse(BaseModel): - id: Optional[int] = None - garmin_activity_id: Optional[str] = None - activity_name: Optional[str] = None - activity_type: Optional[str] = None - start_time: Optional[str] = None - duration: Optional[int] = None - file_path: Optional[str] = None - file_type: Optional[str] = None - download_status: Optional[str] = None - downloaded_at: Optional[str] = None - @router.get("/metrics/list", response_model=MetricsListResponse) -async def list_available_metrics(): - # This would return available metric types and date ranges - # Implementation will connect with the services layer - return { - "metric_types": ["steps", "heart_rate", "sleep", "calories"], - "date_range": { - "start_date": "2023-01-01", - "end_date": "2023-12-31" +async def list_available_metrics(db: Session = Depends(get_db)): + """ + Return available metric types and date ranges. + """ + try: + logger.info("Listing available metrics") + + # Query for distinct metric types from the database + metric_types_result = db.query(HealthMetric.metric_type).distinct().all() + metric_types = [row[0] for row in metric_types_result if row[0] is not None] + + # Find the date range of available metrics + min_date_result = db.query(func.min(HealthMetric.date)).scalar() + max_date_result = db.query(func.max(HealthMetric.date)).scalar() + + start_date = min_date_result.isoformat() if min_date_result else None + end_date = max_date_result.isoformat() if max_date_result else None + + response = { + "metric_types": metric_types, + "date_range": { + "start_date": start_date, + "end_date": end_date + } } - } + + logger.info(f"Returning {len(metric_types)} metric types with date range {start_date} to {end_date}") + return response + except Exception as e: + logger.error(f"Error in list_available_metrics: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error listing metrics: {str(e)}") @router.get("/metrics/query", response_model=List[HealthMetricResponse]) async def query_metrics( metric_type: Optional[str] = Query(None), start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), - limit: int = Query(100, ge=1, le=1000) + limit: int = Query(100, ge=1, le=1000), + db: Session = Depends(get_db) ): - # This would query health metrics with filters - # Implementation will connect with the services layer - return [] + """ + Query health metrics with filters. + """ + try: + logger.info(f"Querying metrics - type: {metric_type}, start: {start_date}, end: {end_date}, limit: {limit}") + + # Start building the query + query = db.query(HealthMetric) + + # Apply filters based on parameters + if metric_type: + query = query.filter(HealthMetric.metric_type == metric_type) + + if start_date: + from datetime import datetime + start_dt = datetime.fromisoformat(start_date) + query = query.filter(HealthMetric.date >= start_dt.date()) + + if end_date: + from datetime import datetime + end_dt = datetime.fromisoformat(end_date) + query = query.filter(HealthMetric.date <= end_dt.date()) + + # Apply limit + query = query.limit(limit) + + # Execute the query + health_metrics = query.all() + + # Convert SQLAlchemy objects to Pydantic models + metric_responses = [] + for metric in health_metrics: + metric_responses.append( + HealthMetricResponse( + id=metric.id, + metric_type=metric.metric_type, + metric_value=metric.metric_value, + unit=metric.unit, + timestamp=metric.timestamp.isoformat() if metric.timestamp else "", + date=metric.date.isoformat() if metric.date else "", + source=metric.source, + detailed_data=metric.detailed_data + ) + ) + + logger.info(f"Returning {len(metric_responses)} health metrics") + return metric_responses + except Exception as e: + logger.error(f"Error in query_metrics: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error querying metrics: {str(e)}") @router.get("/health-data/summary", response_model=HealthDataSummary) async def get_health_summary( - start_date: Optional[str] = Query(None), - end_date: Optional[str] = Query(None) -): - # This would return aggregated health statistics - # Implementation will connect with the services layer - return { - "total_steps": 123456, - "avg_heart_rate": 72.5, - "total_sleep_hours": 210.5, - "avg_calories": 2345.6 - } - -@router.get("/activities/list", response_model=List[ActivityResponse]) -async def list_activities( - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0) -): - # This would return metadata for all downloaded/available activities - # Implementation will connect with the services layer - return [] - -@router.get("/activities/query", response_model=List[ActivityResponse]) -async def query_activities( - activity_type: Optional[str] = Query(None), start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), - download_status: Optional[str] = Query(None) + db: Session = Depends(get_db) ): - # This would allow advanced filtering of activities - # Implementation will connect with the services layer - return [] \ No newline at end of file + """ + Return aggregated health statistics. + """ + try: + logger.info(f"Getting health summary - start: {start_date}, end: {end_date}") + + # Start building the query for steps + steps_query = db.query(func.sum(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'steps') + + # Apply date filters to steps query + if start_date: + from datetime import datetime + start_dt = datetime.fromisoformat(start_date) + steps_query = steps_query.filter(HealthMetric.date >= start_dt.date()) + + if end_date: + from datetime import datetime + end_dt = datetime.fromisoformat(end_date) + steps_query = steps_query.filter(HealthMetric.date <= end_dt.date()) + + # Calculate total steps + total_steps_result = steps_query.scalar() + total_steps = int(total_steps_result) if total_steps_result is not None else 0 + + # Build query for heart rate + hr_query = db.query(func.avg(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'heart_rate') + + # Apply date filters to heart rate query + if start_date: + from datetime import datetime + start_dt = datetime.fromisoformat(start_date) + hr_query = hr_query.filter(HealthMetric.date >= start_dt.date()) + + if end_date: + end_dt = datetime.fromisoformat(end_date) + hr_query = hr_query.filter(HealthMetric.date <= end_dt.date()) + + # Calculate average heart rate + avg_hr_result = hr_query.scalar() + avg_heart_rate = float(avg_hr_result) if avg_hr_result is not None else 0.0 + + # Calculate total sleep hours - assuming sleep data is stored in minutes in the database + sleep_query = db.query(func.sum(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'sleep') + + # Apply date filters to sleep query + if start_date: + from datetime import datetime + start_dt = datetime.fromisoformat(start_date) + sleep_query = sleep_query.filter(HealthMetric.date >= start_dt.date()) + + if end_date: + end_dt = datetime.fromisoformat(end_date) + sleep_query = sleep_query.filter(HealthMetric.date <= end_dt.date()) + + # Calculate total sleep in minutes, then convert to hours + total_sleep_minutes_result = sleep_query.scalar() + total_sleep_hours = (total_sleep_minutes_result / 60) if total_sleep_minutes_result is not None else 0.0 + + # Calculate average calories - assuming we have calories data + calories_query = db.query(func.avg(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'calories') + + # Apply date filters to calories query + if start_date: + from datetime import datetime + start_dt = datetime.fromisoformat(start_date) + calories_query = calories_query.filter(HealthMetric.date >= start_dt.date()) + + if end_date: + end_dt = datetime.fromisoformat(end_date) + calories_query = calories_query.filter(HealthMetric.date <= end_dt.date()) + + # Calculate average calories + avg_calories_result = calories_query.scalar() + avg_calories = float(avg_calories_result) if avg_calories_result is not None else 0.0 + + summary = HealthDataSummary( + total_steps=total_steps, + avg_heart_rate=round(avg_heart_rate, 2), + total_sleep_hours=round(total_sleep_hours, 2), + avg_calories=round(avg_calories, 2) + ) + + logger.info(f"Returning health summary: steps={total_steps}, avg_hr={avg_heart_rate}, sleep_hours={total_sleep_hours}, avg_calories={avg_calories}") + return summary + except Exception as e: + logger.error(f"Error in get_health_summary: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error getting health summary: {str(e)}") \ No newline at end of file diff --git a/FitnessSync/backend/src/api/setup.py b/FitnessSync/backend/src/api/setup.py index 754089b..2e71083 100644 --- a/FitnessSync/backend/src/api/setup.py +++ b/FitnessSync/backend/src/api/setup.py @@ -3,16 +3,14 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel from typing import Optional from sqlalchemy.orm import Session -import traceback -import httpx -import base64 -import json +import logging + +from ..services.garmin.client import GarminClient from ..services.postgresql_manager import PostgreSQLManager from ..utils.config import config -import garth -from ..services.garmin.client import GarminClient router = APIRouter() +logger = logging.getLogger(__name__) def get_db(): db_manager = PostgreSQLManager(config.DATABASE_URL) @@ -24,260 +22,35 @@ class GarminCredentials(BaseModel): password: str is_china: bool = False -class FitbitCredentials(BaseModel): - client_id: str - client_secret: str - -class FitbitCallback(BaseModel): - callback_url: str - class GarminMFARequest(BaseModel): verification_code: str - session_id: str - -class AuthStatusResponse(BaseModel): - garmin: Optional[dict] = None - fitbit: Optional[dict] = None - -class AuthStatusResponse(BaseModel): - garmin: Optional[dict] = None - fitbit: Optional[dict] = None - -@router.post("/setup/load-consul-config") -async def load_consul_config(db: Session = Depends(get_db)): - """ - Load configuration from Consul and save it to the database. - It first tries to use tokens from Consul, if they are not present, it falls back to username/password login. - """ - consul_url = "http://consul.service.dc1.consul:8500/v1/kv/fitbit-garmin-sync/config" - try: - async with httpx.AsyncClient() as client: - response = await client.get(consul_url) - response.raise_for_status() - data = response.json() - if not (data and 'Value' in data[0]): - raise HTTPException(status_code=404, detail="Config not found in Consul") - - config_value = base64.b64decode(data[0]['Value']).decode('utf-8') - config = json.loads(config_value) - - if 'garmin' in config: - garmin_config = config['garmin'] - from ..models.api_token import APIToken - from datetime import datetime - - # Prefer tokens if available - if 'garth_oauth1_token' in garmin_config and 'garth_oauth2_token' in garmin_config: - token_record = db.query(APIToken).filter_by(token_type='garmin').first() - if not token_record: - token_record = APIToken(token_type='garmin') - db.add(token_record) - - token_record.garth_oauth1_token = garmin_config['garth_oauth1_token'] - token_record.garth_oauth2_token = garmin_config['garth_oauth2_token'] - token_record.updated_at = datetime.now() - db.commit() - - return {"status": "success", "message": "Garmin tokens from Consul have been saved."} - - # Fallback to username/password login - elif 'username' in garmin_config and 'password' in garmin_config: - garmin_creds = GarminCredentials(**garmin_config) - garmin_client = GarminClient(garmin_creds.username, garmin_creds.password, garmin_creds.is_china) - status = garmin_client.login() - - if status == "mfa_required": - return {"status": "mfa_required", "message": "Garmin login from Consul requires MFA. Please complete it manually."} - elif status != "success": - raise HTTPException(status_code=400, detail=f"Failed to login to Garmin with Consul credentials: {status}") - - # TODO: Add Fitbit credentials handling - - return {"status": "success", "message": "Configuration from Consul processed."} - - except httpx.RequestError as e: - raise HTTPException(status_code=500, detail=f"Failed to connect to Consul: {e}") - except Exception as e: - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=f"An error occurred: {e}") - -@router.get("/setup/auth-status", response_model=AuthStatusResponse) -async def get_auth_status(db: Session = Depends(get_db)): - from ..models.api_token import APIToken - - garmin_status = {} - fitbit_status = {} - - # Garmin Status - garmin_token = db.query(APIToken).filter_by(token_type='garmin').first() - if garmin_token: - garmin_status = { - "token_stored": True, - "authenticated": garmin_token.garth_oauth1_token is not None and garmin_token.garth_oauth2_token is not None, - "garth_oauth1_token_exists": garmin_token.garth_oauth1_token is not None, - "garth_oauth2_token_exists": garmin_token.garth_oauth2_token is not None, - "mfa_state_exists": garmin_token.mfa_state is not None, - "mfa_expires_at": garmin_token.mfa_expires_at, - "last_used": garmin_token.last_used, - "updated_at": garmin_token.updated_at, - "username": "N/A", # Placeholder, username is not stored in APIToken - "is_china": False # Placeholder - } - else: - garmin_status = { - "token_stored": False, - "authenticated": False - } - - # Fitbit Status (Existing logic, might need adjustment if Fitbit tokens are stored differently) - fitbit_token = db.query(APIToken).filter_by(token_type='fitbit').first() - if fitbit_token: - fitbit_status = { - "token_stored": True, - "authenticated": fitbit_token.access_token is not None, - "client_id": fitbit_token.access_token[:10] + "..." if fitbit_token.access_token else "N/A", - "expires_at": fitbit_token.expires_at, - "last_used": fitbit_token.last_used, - "updated_at": fitbit_token.updated_at - } - else: - fitbit_status = { - "token_stored": False, - "authenticated": False - } - - return AuthStatusResponse( - garmin=garmin_status, - fitbit=fitbit_status - ) @router.post("/setup/garmin") -async def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)): - from ..utils.helpers import setup_logger - logger = setup_logger(__name__) - - logger.info(f"Received Garmin credentials for user: {credentials.username}, is_china: {credentials.is_china}") +def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)): + logger.info(f"Received Garmin credentials for user: {credentials.username}") garmin_client = GarminClient(credentials.username, credentials.password, credentials.is_china) - logger.debug("GarminClient instance created successfully") - logger.debug("Attempting to log in to Garmin") - # Check the status returned directly - status = garmin_client.login() + status = garmin_client.login(db) if status == "mfa_required": - # Hardcode the session_id as 'garmin' since you use a single record in APIToken - return JSONResponse( - status_code=200, - content={ - "status": "mfa_required", - "message": "MFA Required", - "session_id": "garmin" - } - ) + return JSONResponse(status_code=202, content={"status": "mfa_required", "message": "MFA code required."}) - return JSONResponse( - status_code=200, - content={"status": "success", "message": "Logged in!"} - ) + return JSONResponse(status_code=200, content={"status": "success", "message": "Logged in and tokens saved."}) @router.post("/setup/garmin/mfa") -async def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)): - from ..utils.helpers import setup_logger - logger = setup_logger(__name__) +def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)): + logger.info(f"Received MFA verification code: {'*' * len(mfa_request.verification_code)}") try: - logger.info(f"Received MFA verification code for session {mfa_request.session_id}: {'*' * len(mfa_request.verification_code)}") + garmin_client = GarminClient() + success = garmin_client.handle_mfa(db, mfa_request.verification_code) - try: - garmin_client = GarminClient() - logger.debug(f"Attempting to handle MFA for session: {mfa_request.session_id}") + if success: + return JSONResponse(status_code=200, content={"status": "success", "message": "MFA verification successful, tokens saved."}) + else: + raise HTTPException(status_code=400, detail="MFA verification failed.") - success = garmin_client.handle_mfa(mfa_request.verification_code, session_id=mfa_request.session_id) - - if success: - logger.info(f"MFA verification completed successfully for session: {mfa_request.session_id}") - return JSONResponse( - status_code=200, - content={"status": "success", "message": "MFA verification completed successfully"} - ) - else: - logger.error(f"MFA verification failed for session: {mfa_request.session_id}") - return JSONResponse( - status_code=400, - content={"status": "error", "message": "MFA verification failed"} - ) - except Exception as e: - logger.error(f"MFA verification failed for session {mfa_request.session_id} with exception: {str(e)}") - logger.error(f"Exception type: {type(e).__name__}") - logger.error(f"Exception details: {repr(e)}") - logger.error(f"Full traceback: {traceback.format_exc()}") - return JSONResponse( - status_code=500, - content={"status": "error", "message": f"MFA verification failed: {str(e)}"} - ) - except Exception as outer_error: - logger.error(f"Unexpected error in complete_garmin_mfa: {str(outer_error)}") - logger.error(f"Full traceback: {traceback.format_exc()}") - return JSONResponse( - status_code=500, - content={"status": "error", "message": f"Unexpected error: {str(outer_error)}"} - ) - -@router.post("/setup/fitbit") -async def save_fitbit_credentials(credentials: FitbitCredentials, db: Session = Depends(get_db)): - return { - "status": "success", - "auth_url": "https://www.fitbit.com/oauth2/authorize?...", - "message": "Fitbit credentials saved, please visit auth_url to authorize" - } - -@router.post("/setup/fitbit/callback") -async def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db)): - return {"status": "success", "message": "Fitbit OAuth flow completed successfully"} - -@router.post("/setup/garmin/test-token") -async def test_garmin_token(db: Session = Depends(get_db)): - from ..models.api_token import APIToken - from garth.auth_tokens import OAuth1Token, OAuth2Token - import json - - token_record = db.query(APIToken).filter_by(token_type='garmin').first() - if not token_record or not token_record.garth_oauth1_token or not token_record.garth_oauth2_token: - raise HTTPException(status_code=404, detail="Garmin token not found or incomplete.") - - try: - from ..utils.helpers import setup_logger - logger = setup_logger(__name__) - - logger.info("garth_oauth1_token from DB: %s", token_record.garth_oauth1_token) - logger.info("Type of garth_oauth1_token: %s", type(token_record.garth_oauth1_token)) - logger.info("garth_oauth2_token from DB: %s", token_record.garth_oauth2_token) - logger.info("Type of garth_oauth2_token: %s", type(token_record.garth_oauth2_token)) - - if not token_record.garth_oauth1_token or not token_record.garth_oauth2_token: - raise HTTPException(status_code=400, detail="OAuth1 or OAuth2 token is empty.") - - import garth - - # Parse JSON to dictionaries - oauth1_dict = json.loads(token_record.garth_oauth1_token) - oauth2_dict = json.loads(token_record.garth_oauth2_token) - - # Convert to proper token objects - garth.client.oauth1_token = OAuth1Token(**oauth1_dict) - garth.client.oauth2_token = OAuth2Token(**oauth2_dict) - - # Also configure the domain if present - if oauth1_dict.get('domain'): - garth.configure(domain=oauth1_dict['domain']) - - profile_info = garth.UserProfile.get() - return profile_info - except Exception as e: - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=f"Failed to test Garmin token: {e}") - + logger.error(f"MFA verification failed with exception: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"MFA verification failed: {str(e)}") diff --git a/FitnessSync/backend/src/api/status.py b/FitnessSync/backend/src/api/status.py index 2cf35e5..bfb316f 100644 --- a/FitnessSync/backend/src/api/status.py +++ b/FitnessSync/backend/src/api/status.py @@ -1,36 +1,48 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel from typing import List, Optional +from sqlalchemy.orm import Session +from ..services.postgresql_manager import PostgreSQLManager +from ..utils.config import config +from ..models.activity import Activity +from ..models.sync_log import SyncLog +from datetime import datetime router = APIRouter() +def get_db(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + yield session + class SyncLogResponse(BaseModel): id: int operation: str status: str - message: Optional[str] - start_time: str - end_time: Optional[str] + message: Optional[str] = None + start_time: datetime + end_time: Optional[datetime] = None records_processed: int records_failed: int + class Config: + orm_mode = True + class StatusResponse(BaseModel): - total_weight_records: int - synced_weight_records: int - unsynced_weight_records: int total_activities: int downloaded_activities: int recent_logs: List[SyncLogResponse] -@router.get("/status") -async def get_status(): - # This would return the current sync status - # Implementation will connect with the services layer - return { - "total_weight_records": 100, - "synced_weight_records": 85, - "unsynced_weight_records": 15, - "total_activities": 50, - "downloaded_activities": 30, - "recent_logs": [] - } \ No newline at end of file +@router.get("/status", response_model=StatusResponse) +def get_status(db: Session = Depends(get_db)): + """Returns the current sync status and recent logs.""" + total_activities = db.query(Activity).count() + downloaded_activities = db.query(Activity).filter(Activity.download_status == 'downloaded').count() + + recent_logs = db.query(SyncLog).order_by(SyncLog.start_time.desc()).limit(10).all() + + return StatusResponse( + total_activities=total_activities, + downloaded_activities=downloaded_activities, + recent_logs=recent_logs + ) \ No newline at end of file diff --git a/FitnessSync/backend/src/api/sync.py b/FitnessSync/backend/src/api/sync.py index 164a381..8a15f10 100644 --- a/FitnessSync/backend/src/api/sync.py +++ b/FitnessSync/backend/src/api/sync.py @@ -1,11 +1,20 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional +from datetime import datetime +from ..models.api_token import APIToken +from ..services.sync_app import SyncApp +from ..services.garmin.client import GarminClient from ..services.postgresql_manager import PostgreSQLManager from sqlalchemy.orm import Session from ..utils.config import config +import logging +import json +import garth +from garth.auth_tokens import OAuth1Token, OAuth2Token router = APIRouter() +logger = logging.getLogger(__name__) class SyncActivityRequest(BaseModel): days_back: int = 30 @@ -20,32 +29,50 @@ def get_db(): with db_manager.get_db_session() as session: yield session -@router.post("/sync/weight", response_model=SyncResponse) -async def sync_weight(db: Session = Depends(get_db)): - # This would trigger the weight sync process - # Implementation will connect with the services layer - return { - "status": "started", - "message": "Weight sync process started", - "job_id": "weight-sync-12345" - } +def _load_and_verify_garth_session(db: Session): + """Helper to load token from DB and verify session with Garmin.""" + logger.info("Loading and verifying Garmin session...") + token_record = db.query(APIToken).filter_by(token_type='garmin').first() + if not (token_record and token_record.garth_oauth1_token and token_record.garth_oauth2_token): + raise HTTPException(status_code=401, detail="Garmin token not found.") + + try: + oauth1_dict = json.loads(token_record.garth_oauth1_token) + oauth2_dict = json.loads(token_record.garth_oauth2_token) + + domain = oauth1_dict.get('domain') + if domain: + garth.configure(domain=domain) + + garth.client.oauth1_token = OAuth1Token(**oauth1_dict) + garth.client.oauth2_token = OAuth2Token(**oauth2_dict) + + garth.UserProfile.get() + logger.info("Garth session verified.") + except Exception as e: + logger.error(f"Garth session verification failed: {e}", exc_info=True) + raise HTTPException(status_code=401, detail=f"Failed to authenticate with Garmin: {e}") @router.post("/sync/activities", response_model=SyncResponse) -async def sync_activities(request: SyncActivityRequest, db: Session = Depends(get_db)): - # This would trigger the activity sync process - # Implementation will connect with the services layer - return { - "status": "started", - "message": "Activity sync process started", - "job_id": f"activity-sync-{request.days_back}" - } +def sync_activities(request: SyncActivityRequest, db: Session = Depends(get_db)): + _load_and_verify_garth_session(db) + garmin_client = GarminClient() # The client is now just a thin wrapper + sync_app = SyncApp(db_session=db, garmin_client=garmin_client) + result = sync_app.sync_activities(days_back=request.days_back) + return SyncResponse( + status=result.get("status", "completed_with_errors" if result.get("failed", 0) > 0 else "completed"), + message=f"Activity sync completed: {result.get('processed', 0)} processed, {result.get('failed', 0)} failed", + job_id=f"activity-sync-{datetime.now().strftime('%Y%m%d%H%M%S')}" + ) @router.post("/sync/metrics", response_model=SyncResponse) -async def sync_metrics(db: Session = Depends(get_db)): - # This would trigger the health metrics sync process - # Implementation will connect with the services layer - return { - "status": "started", - "message": "Health metrics sync process started", - "job_id": "metrics-sync-12345" - } \ No newline at end of file +def sync_metrics(db: Session = Depends(get_db)): + _load_and_verify_garth_session(db) + garmin_client = GarminClient() + sync_app = SyncApp(db_session=db, garmin_client=garmin_client) + result = sync_app.sync_health_metrics() + return SyncResponse( + status=result.get("status", "completed_with_errors" if result.get("failed", 0) > 0 else "completed"), + message=f"Health metrics sync completed: {result.get('processed', 0)} processed, {result.get('failed', 0)} failed", + job_id=f"metrics-sync-{datetime.now().strftime('%Y%m%d%H%M%S')}" + ) diff --git a/FitnessSync/backend/src/services/garmin/auth.py b/FitnessSync/backend/src/services/garmin/auth.py index dea2565..28ac9dd 100644 --- a/FitnessSync/backend/src/services/garmin/auth.py +++ b/FitnessSync/backend/src/services/garmin/auth.py @@ -2,103 +2,89 @@ import garth import json from datetime import datetime, timedelta from garth.exc import GarthException -from src.models.api_token import APIToken -from src.services.postgresql_manager import PostgreSQLManager -from src.utils.config import config -from src.utils.helpers import setup_logger +from sqlalchemy.orm import Session +import logging -logger = setup_logger(__name__) +from ...models.api_token import APIToken + +logger = logging.getLogger(__name__) class AuthMixin: - def login(self): + def login(self, db: Session): """Login to Garmin Connect, returning status instead of raising exceptions.""" logger.info(f"Starting login for: {self.username}") try: - # result1 is status, result2 is the mfa_state dict or tokens - result1, result2 = garth.login(self.username, self.password, return_on_mfa=True) - - if result1 == "needs_mfa": - logger.info("MFA required for Garmin authentication.") - self.initiate_mfa(result2) # Fixed below - return "mfa_required" - - self.update_tokens(result1, result2) + garth.login(self.username, self.password) + self.update_tokens(db, garth.client.oauth1_token, garth.client.oauth2_token) self.is_connected = True return "success" except GarthException as e: + if "needs-mfa" in str(e).lower(): + logger.info("MFA required for Garmin authentication.") + self.initiate_mfa(db, garth.client.mfa_state) + return "mfa_required" logger.error(f"Login failed: {e}") return "error" - def update_tokens(self, oauth1, oauth2): + def update_tokens(self, db: Session, oauth1: dict, oauth2: dict): """Saves the Garmin OAuth tokens to the database.""" logger.info(f"Updating Garmin tokens for user: {self.username}") - db_manager = PostgreSQLManager(config.DATABASE_URL) - with db_manager.get_db_session() as session: - token_record = session.query(APIToken).filter_by(token_type='garmin').first() - if not token_record: - token_record = APIToken(token_type='garmin') - session.add(token_record) - - token_record.garth_oauth1_token = json.dumps(oauth1) - token_record.garth_oauth2_token = json.dumps(oauth2) - token_record.updated_at = datetime.now() - - # Clear MFA state as it's no longer needed - token_record.mfa_state = None - token_record.mfa_expires_at = None - - session.commit() - logger.info("Garmin tokens updated successfully.") + token_record = db.query(APIToken).filter_by(token_type='garmin').first() + if not token_record: + token_record = APIToken(token_type='garmin') + db.add(token_record) + + token_record.garth_oauth1_token = json.dumps(oauth1) + token_record.garth_oauth2_token = json.dumps(oauth2) + token_record.updated_at = datetime.now() + + token_record.mfa_state = None + token_record.mfa_expires_at = None + + db.commit() + logger.info("Garmin tokens updated successfully.") - def initiate_mfa(self, mfa_state): + def initiate_mfa(self, db: Session, mfa_state: dict): """Saves ONLY serializable parts of the MFA state to the database.""" logger.info(f"Initiating MFA process for user: {self.username}") - # FIX: Extract serializable data. We cannot dump the 'client' object directly. serializable_state = { "signin_params": mfa_state["signin_params"], "cookies": mfa_state["client"].sess.cookies.get_dict(), "domain": mfa_state["client"].domain } - db_manager = PostgreSQLManager(config.DATABASE_URL) - with db_manager.get_db_session() as session: - token_record = session.query(APIToken).filter_by(token_type='garmin').first() - if not token_record: - token_record = APIToken(token_type='garmin') - session.add(token_record) - - # Save the dictionary as a string - token_record.mfa_state = json.dumps(serializable_state) - token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10) - session.commit() + token_record = db.query(APIToken).filter_by(token_type='garmin').first() + if not token_record: + token_record = APIToken(token_type='garmin') + db.add(token_record) + + token_record.mfa_state = json.dumps(serializable_state) + token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10) + db.commit() - def handle_mfa(self, verification_code: str, session_id: str = None): + def handle_mfa(self, db: Session, verification_code: str): """Reconstructs the Garth state and completes authentication.""" - db_manager = PostgreSQLManager(config.DATABASE_URL) - with db_manager.get_db_session() as session: - token_record = session.query(APIToken).filter_by(token_type='garmin').first() - if not token_record or not token_record.mfa_state: - raise Exception("No pending MFA session found.") - - saved_data = json.loads(token_record.mfa_state) - - # FIX: Reconstruct the Garth Client and State object - from garth.http import Client - client = Client(domain=saved_data["domain"]) - client.sess.cookies.update(saved_data["cookies"]) - - mfa_state = { - "client": client, - "signin_params": saved_data["signin_params"] - } - - try: - oauth1, oauth2 = garth.resume_login(mfa_state, verification_code) - self.update_tokens(oauth1, oauth2) - # ... rest of your session cleanup ... - return True - except GarthException as e: - logger.error(f"MFA handling failed: {e}") - raise \ No newline at end of file + token_record = db.query(APIToken).filter_by(token_type='garmin').first() + if not token_record or not token_record.mfa_state: + raise Exception("No pending MFA session found.") + + saved_data = json.loads(token_record.mfa_state) + + from garth.http import Client + client = Client(domain=saved_data["domain"]) + client.sess.cookies.update(saved_data["cookies"]) + + mfa_state = { + "client": client, + "signin_params": saved_data["signin_params"] + } + + try: + garth.resume_login(mfa_state, verification_code) + self.update_tokens(db, garth.client.oauth1_token, garth.client.oauth2_token) + return True + except GarthException as e: + logger.error(f"MFA handling failed: {e}") + raise diff --git a/FitnessSync/backend/src/services/garmin/client.py b/FitnessSync/backend/src/services/garmin/client.py index 51c8910..5b7ba4b 100644 --- a/FitnessSync/backend/src/services/garmin/client.py +++ b/FitnessSync/backend/src/services/garmin/client.py @@ -1,9 +1,9 @@ import garth -from src.utils.helpers import setup_logger +import logging from .auth import AuthMixin from .data import DataMixin -logger = setup_logger(__name__) +logger = logging.getLogger(__name__) class GarminClient(AuthMixin, DataMixin): def __init__(self, username: str = None, password: str = None, is_china: bool = False): @@ -13,10 +13,7 @@ class GarminClient(AuthMixin, DataMixin): self.garmin_client = None self.is_connected = False - logger.debug(f"Initializing GarminClient for user: {username}, is_china: {is_china}") - if is_china: - logger.debug("Configuring garth for China domain") garth.configure(domain="garmin.cn") if username and password: diff --git a/FitnessSync/backend/src/services/garmin/data.py b/FitnessSync/backend/src/services/garmin/data.py index 2904a9f..fd013b2 100644 --- a/FitnessSync/backend/src/services/garmin/data.py +++ b/FitnessSync/backend/src/services/garmin/data.py @@ -1,139 +1,75 @@ -from datetime import datetime +from datetime import datetime, timedelta from typing import List, Dict, Any, Optional -from src.utils.helpers import setup_logger +import garth +from garth.stats.steps import DailySteps +from garth.stats.hrv import DailyHRV +from garth.data.sleep import SleepData +import logging + +logger = logging.getLogger(__name__) -logger = setup_logger(__name__) class DataMixin: - def upload_weight(self, weight: float, unit: str = 'kg', timestamp: datetime = None) -> bool: - """Upload weight entry to Garmin Connect.""" - if not self.is_connected: - raise Exception("Not connected to Garmin Connect") - - try: - if not timestamp: - timestamp = datetime.now() - - try: - result = self.garmin_client.add_body_composition( - timestamp=timestamp, - weight=weight - ) - except Exception: - try: - result = self.garmin_client.add_body_composition( - timestamp=timestamp.isoformat(), - weight=weight - ) - except Exception: - result = self.garmin_client.add_body_composition( - timestamp=timestamp.strftime('%Y-%m-%d'), - weight=weight - ) - - logger.info(f"Successfully uploaded weight: {weight} {unit} at {timestamp}") - return result is not None - except Exception as e: - logger.error(f"Error uploading weight to Garmin: {str(e)}") - if "401" in str(e) or "unauthorized" in str(e).lower(): - logger.error("Authentication failed - need to re-authenticate") - raise Exception("Authentication expired, needs re-authentication") - raise e + """ + Mixin for Garmin data fetching operations using the garth library. + Assumes that the global garth client has been authenticated. + """ - def get_activities(self, start_date: str, end_date: str = None, limit: int = 100) -> List[Dict[str, Any]]: + def get_activities(self, start_date: str, end_date: str, limit: int = 100) -> List[Dict[str, Any]]: """Fetch activity list from Garmin Connect.""" - if not self.is_connected: - raise Exception("Not connected to Garmin Connect") - + logger.info(f"Fetching activities from {start_date} to {end_date}") try: - if not end_date: - end_date = datetime.now().strftime('%Y-%m-%d') - - activities = self.garmin_client.get_activities(start_date, end_date) - logger.info(f"Fetched {len(activities)} activities from Garmin") - return activities + return garth.client.connectapi( + "/activitylist-service/activities/search/activities", + params={"startDate": start_date, "endDate": end_date, "limit": limit} + ) except Exception as e: - logger.error(f"Error fetching activities from Garmin: {str(e)}") - raise e + logger.error(f"Error fetching activities from Garmin: {e}") + raise - def download_activity(self, activity_id: str, file_type: str = 'tcx') -> Optional[bytes]: - """Download activity file from Garmin Connect and return its content.""" - if not self.is_connected: - raise Exception("Not connected to Garmin Connect") - + def download_activity(self, activity_id: str, file_type: str = 'original') -> Optional[bytes]: + """ + Download an activity file from Garmin Connect. + 'file_type' can be 'tcx', 'gpx', 'fit', or 'original'. + """ + logger.info(f"Downloading activity {activity_id} as {file_type}") try: - file_content = self.garmin_client.get_activity_details(activity_id) - logger.info(f"Downloaded activity {activity_id} as {file_type} format") - return file_content if file_content else b"" + path = f"/download-service/export/{file_type}/activity/{activity_id}" + return garth.client.download(path) except Exception as e: - logger.error(f"Error downloading activity {activity_id} from Garmin: {str(e)}") - raise e + logger.error(f"Error downloading activity {activity_id} as {file_type}: {e}") + return None - def get_heart_rates(self, start_date: str, end_date: str = None) -> Dict[str, Any]: - """Fetch heart rate data from Garmin Connect.""" - if not self.is_connected: - raise Exception("Not connected to Garmin Connect") - - try: - if not end_date: - end_date = datetime.now().strftime('%Y-%m-%d') - - heart_rates = self.garmin_client.get_heart_rates(start_date, end_date) - logger.info(f"Fetched heart rate data from Garmin for {start_date} to {end_date}") - return heart_rates - except Exception as e: - logger.error(f"Error fetching heart rate data from Garmin: {str(e)}") - raise e + def get_daily_metrics(self, start_date: str, end_date: str) -> Dict[str, List[Dict]]: + """ + Fetch various daily metrics for a given date range. + """ + start = datetime.strptime(start_date, '%Y-%m-%d').date() + end = datetime.strptime(end_date, '%Y-%m-%d').date() + days = (end - start).days + 1 - def get_sleep_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]: - """Fetch sleep data from Garmin Connect.""" - if not self.is_connected: - raise Exception("Not connected to Garmin Connect") - - try: - if not end_date: - end_date = datetime.now().strftime('%Y-%m-%d') - - sleep_data = self.garmin_client.get_sleep_data(start_date, end_date) - logger.info(f"Fetched sleep data from Garmin for {start_date} to {end_date}") - return sleep_data - except Exception as e: - logger.error(f"Error fetching sleep data from Garmin: {str(e)}") - raise e + all_metrics = { + "steps": [], + "hrv": [], + "sleep": [] + } - def get_steps_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]: - """Fetch steps data from Garmin Connect.""" - if not self.is_connected: - raise Exception("Not connected to Garmin Connect") - try: - if not end_date: - end_date = datetime.now().strftime('%Y-%m-%d') - - steps_data = self.garmin_client.get_steps_data(start_date, end_date) - logger.info(f"Fetched steps data from Garmin for {start_date} to {end_date}") - return steps_data + logger.info(f"Fetching daily steps for {days} days ending on {end_date}") + all_metrics["steps"] = DailySteps.list(end, period=days) except Exception as e: - logger.error(f"Error fetching steps data from Garmin: {str(e)}") - raise e + logger.error(f"Error fetching daily steps: {e}") - def get_all_metrics(self, start_date: str, end_date: str = None) -> Dict[str, Any]: - """Fetch all available metrics from Garmin Connect.""" - if not self.is_connected: - raise Exception("Not connected to Garmin Connect") - try: - if not end_date: - end_date = datetime.now().strftime('%Y-%m-%d') - - metrics = { - 'heart_rates': self.get_heart_rates(start_date, end_date), - 'sleep_data': self.get_sleep_data(start_date, end_date), - 'steps_data': self.get_steps_data(start_date, end_date), - } - - logger.info(f"Fetched all metrics from Garmin for {start_date} to {end_date}") - return metrics + logger.info(f"Fetching daily HRV for {days} days ending on {end_date}") + all_metrics["hrv"] = DailyHRV.list(end, period=days) except Exception as e: - logger.error(f"Error fetching all metrics from Garmin: {str(e)}") - raise e + logger.error(f"Error fetching daily HRV: {e}") + + try: + logger.info(f"Fetching daily sleep for {days} days ending on {end_date}") + all_metrics["sleep"] = SleepData.list(end, days=days) + except Exception as e: + logger.error(f"Error fetching daily sleep: {e}") + + return all_metrics diff --git a/FitnessSync/backend/src/services/sync_app.py b/FitnessSync/backend/src/services/sync_app.py index 25274cb..3eb9616 100644 --- a/FitnessSync/backend/src/services/sync_app.py +++ b/FitnessSync/backend/src/services/sync_app.py @@ -1,322 +1,182 @@ -from ..models.weight_record import WeightRecord +from ..models.activity import Activity +from ..models.health_metric import HealthMetric from ..models.sync_log import SyncLog -from ..services.fitbit_client import FitbitClient from ..services.garmin.client import GarminClient from sqlalchemy.orm import Session from datetime import datetime, timedelta from typing import Dict import logging -from ..utils.helpers import setup_logger -logger = setup_logger(__name__) +logger = logging.getLogger(__name__) class SyncApp: - def __init__(self, db_session: Session, fitbit_client: FitbitClient, garmin_client: GarminClient): + def __init__(self, db_session: Session, garmin_client: GarminClient, fitbit_client=None): self.db_session = db_session - self.fitbit_client = fitbit_client self.garmin_client = garmin_client - - def sync_weight_data(self, start_date: str = None, end_date: str = None) -> Dict[str, int]: - """Sync weight data from Fitbit to Garmin.""" - if not start_date: - # Default to 1 year back - start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d') - - if not end_date: - end_date = datetime.now().strftime('%Y-%m-%d') - - # Create a sync log entry - sync_log = SyncLog( - operation="weight_sync", - status="started", - start_time=datetime.now(), - records_processed=0, - records_failed=0 - ) - self.db_session.add(sync_log) - self.db_session.commit() - - try: - # Fetch unsynced weight records from Fitbit - fitbit_weights = self.fitbit_client.get_weight_logs(start_date, end_date) - - # Track processing results - processed_count = 0 - failed_count = 0 - - for weight_entry in fitbit_weights: - try: - # Check if this weight entry already exists in our DB (prevents duplicates) - fitbit_id = weight_entry.get('logId', str(weight_entry.get('date', '') + str(weight_entry.get('weight', 0)))) - - existing_record = self.db_session.query(WeightRecord).filter( - WeightRecord.fitbit_id == fitbit_id - ).first() - - if existing_record and existing_record.sync_status == 'synced': - # Skip if already synced - continue - - # Create or update weight record - if not existing_record: - weight_record = WeightRecord( - fitbit_id=fitbit_id, - weight=weight_entry.get('weight'), - unit=weight_entry.get('unit', 'kg'), - date=datetime.fromisoformat(weight_entry.get('date')) if isinstance(weight_entry.get('date'), str) else weight_entry.get('date'), - timestamp=datetime.fromisoformat(weight_entry.get('date')) if isinstance(weight_entry.get('date'), str) else weight_entry.get('date'), - sync_status='unsynced' - ) - self.db_session.add(weight_record) - self.db_session.flush() # Get the ID - else: - weight_record = existing_record - - # Upload to Garmin if not already synced - if weight_record.sync_status != 'synced': - # Upload weight to Garmin - success = self.garmin_client.upload_weight( - weight=weight_record.weight, - unit=weight_record.unit, - timestamp=weight_record.timestamp - ) - - if success: - weight_record.sync_status = 'synced' - weight_record.garmin_id = "garmin_" + fitbit_id # Placeholder for Garmin ID - else: - weight_record.sync_status = 'failed' - failed_count += 1 - - processed_count += 1 - - except Exception as e: - logger.error(f"Error processing weight entry: {str(e)}") - failed_count += 1 - - # Update sync log with results - sync_log.status = "completed" if failed_count == 0 else "completed_with_errors" - sync_log.end_time = datetime.now() - sync_log.records_processed = processed_count - sync_log.records_failed = failed_count - - self.db_session.commit() - - logger.info(f"Weight sync completed: {processed_count} processed, {failed_count} failed") - return { - "processed": processed_count, - "failed": failed_count - } - - except Exception as e: - logger.error(f"Error during weight sync: {str(e)}") - - # Update sync log with error status - sync_log.status = "failed" - sync_log.end_time = datetime.now() - sync_log.message = str(e) - - self.db_session.commit() - raise e + self.fitbit_client = fitbit_client + self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + self.logger.info("SyncApp initialized") def sync_activities(self, days_back: int = 30) -> Dict[str, int]: """Sync activity data from Garmin to local storage.""" + self.logger.info(f"=== Starting sync_activities with days_back={days_back} ===") + start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d') end_date = datetime.now().strftime('%Y-%m-%d') - # Create a sync log entry - sync_log = SyncLog( - operation="activity_archive", - status="started", - start_time=datetime.now(), - records_processed=0, - records_failed=0 - ) + self.logger.info(f"Date range: {start_date} to {end_date}") + + sync_log = SyncLog(operation="activity_sync", status="started", start_time=datetime.now()) self.db_session.add(sync_log) self.db_session.commit() + processed_count = 0 + failed_count = 0 + try: - # Fetch activities from Garmin + self.logger.info("Fetching activities from Garmin...") garmin_activities = self.garmin_client.get_activities(start_date, end_date) + self.logger.info(f"Successfully fetched {len(garmin_activities)} activities from Garmin") - processed_count = 0 - failed_count = 0 - - from ..models.activity import Activity - for activity in garmin_activities: + for activity_data in garmin_activities: + activity_id = str(activity_data.get('activityId')) + if not activity_id: + self.logger.warning("Skipping activity with no ID.") + continue + try: - activity_id = str(activity.get('activityId', '')) - existing_activity = self.db_session.query(Activity).filter( - Activity.garmin_activity_id == activity_id - ).first() + existing_activity = self.db_session.query(Activity).filter_by(garmin_activity_id=activity_id).first() - if existing_activity and existing_activity.download_status == 'downloaded': - # Skip if already downloaded - continue - - # Create or update activity record if not existing_activity: - activity_record = Activity( + activity_type_dict = activity_data.get('activityType', {}) + existing_activity = Activity( garmin_activity_id=activity_id, - activity_name=activity.get('activityName', ''), - activity_type=activity.get('activityType', ''), - start_time=datetime.fromisoformat(activity.get('startTimeLocal', '')) if activity.get('startTimeLocal') else None, - duration=activity.get('duration', 0), + activity_name=activity_data.get('activityName'), + activity_type=activity_type_dict.get('typeKey', 'unknown'), + start_time=datetime.fromisoformat(activity_data.get('startTimeLocal')) if activity_data.get('startTimeLocal') else None, + duration=activity_data.get('duration', 0), download_status='pending' ) - self.db_session.add(activity_record) - self.db_session.flush() - else: - activity_record = existing_activity + self.db_session.add(existing_activity) - # Download activity file if not already downloaded - if activity_record.download_status != 'downloaded': - # Download in various formats - file_formats = ['tcx', 'gpx', 'fit'] + if existing_activity.download_status != 'downloaded': downloaded_successfully = False - - for fmt in file_formats: - try: - # Get file content from Garmin client - file_content = self.garmin_client.download_activity(activity_id, file_type=fmt) - if file_content: - # Store file content directly in the database - activity_record.file_content = file_content - activity_record.file_type = fmt - activity_record.download_status = 'downloaded' - activity_record.downloaded_at = datetime.now() - downloaded_successfully = True - break - except Exception as e: - logger.warning(f"Could not download activity {activity_id} in {fmt} format: {str(e)}") - continue + for fmt in ['original', 'tcx', 'gpx', 'fit']: + file_content = self.garmin_client.download_activity(activity_id, file_type=fmt) + if file_content: + existing_activity.file_content = file_content + existing_activity.file_type = fmt + existing_activity.download_status = 'downloaded' + existing_activity.downloaded_at = datetime.now() + self.logger.info(f"✓ Successfully downloaded {activity_id} as {fmt}") + downloaded_successfully = True + break if not downloaded_successfully: - activity_record.download_status = 'failed' + existing_activity.download_status = 'failed' + self.logger.warning(f"✗ Failed to download {activity_id}") failed_count += 1 - - processed_count += 1 + else: + processed_count += 1 + else: + self.logger.info(f"Activity {activity_id} already downloaded. Skipping.") + + self.db_session.commit() except Exception as e: - logger.error(f"Error processing activity {activity.get('activityId', '')}: {str(e)}") + self.logger.error(f"✗ Error processing activity {activity_id}: {e}", exc_info=True) failed_count += 1 + self.db_session.rollback() - # Update sync log with results - sync_log.status = "completed" if failed_count == 0 else "completed_with_errors" - sync_log.end_time = datetime.now() + sync_log.status = "completed_with_errors" if failed_count > 0 else "completed" sync_log.records_processed = processed_count sync_log.records_failed = failed_count - self.db_session.commit() - - logger.info(f"Activity sync completed: {processed_count} processed, {failed_count} failed") - return { - "processed": processed_count, - "failed": failed_count - } - except Exception as e: - logger.error(f"Error during activity sync: {str(e)}") - - # Update sync log with error status + self.logger.error(f"Major error during activity sync: {e}", exc_info=True) sync_log.status = "failed" - sync_log.end_time = datetime.now() sync_log.message = str(e) - - self.db_session.commit() - raise e - - def sync_health_metrics(self, start_date: str = None, end_date: str = None) -> Dict[str, int]: - """Sync health metrics from Garmin to local database.""" - if not start_date: - # Default to 1 year back - start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d') - if not end_date: - end_date = datetime.now().strftime('%Y-%m-%d') - - # Create a sync log entry - sync_log = SyncLog( - operation="metrics_download", - status="started", - start_time=datetime.now(), - records_processed=0, - records_failed=0 - ) - self.db_session.add(sync_log) + sync_log.end_time = datetime.now() self.db_session.commit() + self.logger.info(f"=== Finished sync_activities: processed={processed_count}, failed={failed_count} ===") + return {"processed": processed_count, "failed": failed_count} + + def sync_health_metrics(self, days_back: int = 30) -> Dict[str, int]: + """Sync health metrics from Garmin to local database.""" + start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d') + end_date = datetime.now().strftime('%Y-%m-%d') + + self.logger.info(f"=== Starting sync_health_metrics with days_back={days_back} ===") + sync_log = SyncLog(operation="health_metric_sync", status="started", start_time=datetime.now()) + self.db_session.add(sync_log) + self.db_session.commit() + + processed_count = 0 + failed_count = 0 + try: - # Fetch all metrics from Garmin - all_metrics = self.garmin_client.get_all_metrics(start_date, end_date) - - processed_count = 0 - failed_count = 0 - - from ..models.health_metric import HealthMetric - # Process heart rate data - heart_rates = all_metrics.get('heart_rates', {}) - if 'heartRateValues' in heart_rates: - for hr_data in heart_rates['heartRateValues']: - try: - timestamp = datetime.fromisoformat(hr_data[0]) if isinstance(hr_data[0], str) else datetime.fromtimestamp(hr_data[0]/1000) - metric = HealthMetric( - metric_type='heart_rate', - metric_value=hr_data[1], - unit='bpm', - timestamp=timestamp, - date=timestamp.date(), - source='garmin', - detailed_data=None - ) - self.db_session.add(metric) - processed_count += 1 - except Exception as e: - logger.error(f"Error processing heart rate data: {str(e)}") - failed_count += 1 - - # Process other metrics similarly... - # For brevity, I'll show just one more example - sleep_data = all_metrics.get('sleep_data', {}) - sleep_levels = sleep_data.get('sleep', []) - for sleep_entry in sleep_levels: + daily_metrics = self.garmin_client.get_daily_metrics(start_date, end_date) + + for steps_data in daily_metrics.get("steps", []): try: - metric = HealthMetric( - metric_type='sleep', - metric_value=sleep_entry.get('duration', 0), - unit='minutes', - timestamp=datetime.now(), # Actual timestamp would come from data - date=datetime.now().date(), # Actual date would come from data - source='garmin', - detailed_data=sleep_entry - ) - self.db_session.add(metric) + self._update_or_create_metric('steps', steps_data.calendar_date, steps_data.total_steps, 'steps') processed_count += 1 except Exception as e: - logger.error(f"Error processing sleep data: {str(e)}") + self.logger.error(f"Error processing steps data: {e}", exc_info=True) failed_count += 1 - # Update sync log with results - sync_log.status = "completed" if failed_count == 0 else "completed_with_errors" - sync_log.end_time = datetime.now() + for hrv_data in daily_metrics.get("hrv", []): + try: + self._update_or_create_metric('hrv', hrv_data.calendar_date, hrv_data.last_night_avg, 'ms') + processed_count += 1 + except Exception as e: + self.logger.error(f"Error processing HRV data: {e}", exc_info=True) + failed_count += 1 + + for sleep_data in daily_metrics.get("sleep", []): + try: + self._update_or_create_metric('sleep', sleep_data.daily_sleep_dto.calendar_date, sleep_data.daily_sleep_dto.sleep_time_seconds, 'seconds') + processed_count += 1 + except Exception as e: + self.logger.error(f"Error processing sleep data: {e}", exc_info=True) + failed_count += 1 + + sync_log.status = "completed_with_errors" if failed_count > 0 else "completed" sync_log.records_processed = processed_count sync_log.records_failed = failed_count - - self.db_session.commit() - - logger.info(f"Health metrics sync completed: {processed_count} processed, {failed_count} failed") - return { - "processed": processed_count, - "failed": failed_count - } - + except Exception as e: - logger.error(f"Error during health metrics sync: {str(e)}") - - # Update sync log with error status + self.logger.error(f"Major error during health metrics sync: {e}", exc_info=True) sync_log.status = "failed" - sync_log.end_time = datetime.now() sync_log.message = str(e) - + + sync_log.end_time = datetime.now() + self.db_session.commit() + + self.logger.info(f"=== Finished sync_health_metrics: processed={processed_count}, failed={failed_count} ===") + return {"processed": processed_count, "failed": failed_count} + + def _update_or_create_metric(self, metric_type: str, date: datetime.date, value: float, unit: str): + """Helper to update or create a health metric record.""" + try: + existing = self.db_session.query(HealthMetric).filter_by(metric_type=metric_type, date=date).first() + if existing: + existing.metric_value = value + existing.updated_at = datetime.now() + else: + metric = HealthMetric( + metric_type=metric_type, + metric_value=value, + unit=unit, + timestamp=datetime.combine(date, datetime.min.time()), + date=date, + source='garmin' + ) + self.db_session.add(metric) self.db_session.commit() - raise e \ No newline at end of file + except Exception as e: + self.logger.error(f"Error saving metric {metric_type} for {date}: {e}", exc_info=True) + self.db_session.rollback() + raise diff --git a/FitnessSync/backend/src/utils/helpers.py b/FitnessSync/backend/src/utils/helpers.py index 53aff98..28b6118 100644 --- a/FitnessSync/backend/src/utils/helpers.py +++ b/FitnessSync/backend/src/utils/helpers.py @@ -3,21 +3,6 @@ from datetime import datetime from typing import Optional import os -def setup_logger(name: str, level=logging.DEBUG): - """Function to setup a logger that writes to the console.""" - formatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s %(message)s') - - console_handler = logging.StreamHandler() - console_handler.setFormatter(formatter) - - logger = logging.getLogger(name) - logger.setLevel(level) - - if not logger.handlers: - logger.addHandler(console_handler) - - return logger - def get_current_timestamp() -> str: """Get current timestamp in ISO format.""" return datetime.utcnow().isoformat() @@ -33,4 +18,4 @@ def validate_environment_vars(required_vars: list) -> bool: print(f"Missing required environment variables: {', '.join(missing_vars)}") return False - return True \ No newline at end of file + return True diff --git a/FitnessSync/backend/src/utils/logging_config.py b/FitnessSync/backend/src/utils/logging_config.py new file mode 100644 index 0000000..94b8bd9 --- /dev/null +++ b/FitnessSync/backend/src/utils/logging_config.py @@ -0,0 +1,28 @@ +import logging +import logging.config + +LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": "INFO", + "formatter": "default", + "stream": "ext://sys.stdout", + }, + }, + "root": { + "level": "INFO", + "handlers": ["console"], + }, +} + +def setup_logging(): + """Setup logging configuration.""" + logging.config.dictConfig(LOGGING_CONFIG) diff --git a/FitnessSync/backend/templates/index.html b/FitnessSync/backend/templates/index.html index 937406f..bd9e4e2 100644 --- a/FitnessSync/backend/templates/index.html +++ b/FitnessSync/backend/templates/index.html @@ -10,18 +10,20 @@

Fitbit-Garmin Sync Dashboard

-
-
-
-
-
Weight Records
-

Total: 0

-

Synced: 0

-

Unsynced: 0

-
+ +
+ -
+
+ +
+
Activities
@@ -30,13 +32,13 @@
-
+
-
Sync Status
+
Sync Controls
- - + +
@@ -53,13 +55,15 @@ Operation Status Start Time - Records Processed - Records Failed + End Time + Processed + Failed + Message - Loading logs... + Loading logs... @@ -69,71 +73,11 @@
-

Health Metrics

+

Actions

-
- - - -
-
-
-
-
- -
-
-

Activity Files

-
-
-
- - -
-
-
-
-
- - -
- -
-
-

Health Metrics

-
-
-
- - - -
-
-
-
-
- -
-
-

Activity Files

-
-
-
- - -
+ Setup & Configuration + API Documentation
@@ -142,167 +86,124 @@ - \ No newline at end of file + diff --git a/FitnessSync/specs/002-fitbit-garmin-sync/checklists/requirements.md b/FitnessSync/specs/002-fitbit-garmin-sync/checklists/requirements.md new file mode 100644 index 0000000..996a460 --- /dev/null +++ b/FitnessSync/specs/002-fitbit-garmin-sync/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Fitbit/Garmin Data Sync Implementation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-12-24 +**Feature**: [Link to spec.md](./spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- The initial specification was highly technical based on the user prompt. It has been revised to focus on user-facing requirements and outcomes, making it suitable for planning. \ No newline at end of file diff --git a/FitnessSync/specs/002-fitbit-garmin-sync/contracts/api-contract.yaml b/FitnessSync/specs/002-fitbit-garmin-sync/contracts/api-contract.yaml new file mode 100644 index 0000000..b8313d7 --- /dev/null +++ b/FitnessSync/specs/002-fitbit-garmin-sync/contracts/api-contract.yaml @@ -0,0 +1,273 @@ +openapi: 3.0.0 +info: + title: Fitbit/Garmin Sync API + version: 1.0.0 + description: API for synchronizing and retrieving fitness data from Garmin. + +paths: + /api/sync/activities: + post: + summary: Trigger Activity Sync + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + days_back: + type: integer + default: 7 + responses: + '200': + description: Sync completed + content: + application/json: + schema: + $ref: '#/components/schemas/SyncResponse' + '400': + description: Bad Request (e.g., Garmin not configured) + '500': + description: Internal Server Error + + /api/sync/metrics: + post: + summary: Trigger Health Metrics Sync + responses: + '200': + description: Sync completed + content: + application/json: + schema: + $ref: '#/components/schemas/SyncResponse' + '400': + description: Bad Request (e.g., Garmin not configured) + '500': + description: Internal Server Error + + /api/activities/list: + get: + summary: List Activities + parameters: + - name: limit + in: query + schema: + type: integer + default: 50 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + '200': + description: A list of activities + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ActivityResponse' + + /api/activities/query: + get: + summary: Query Activities + parameters: + - name: activity_type + in: query + schema: + type: string + - name: start_date + in: query + schema: + type: string + format: date + - name: end_date + in: query + schema: + type: string + format: date + - name: download_status + in: query + schema: + type: string + responses: + '200': + description: A list of activities + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ActivityResponse' + + /api/activities/download/{activity_id}: + get: + summary: Download Activity File + parameters: + - name: activity_id + in: path + required: true + schema: + type: string + responses: + '200': + description: The activity file + content: + application/octet-stream: {} + '404': + description: Activity not found or file not downloaded + + /api/metrics/list: + get: + summary: List Available Metrics + responses: + '200': + description: A list of available metric types and date ranges + content: + application/json: + schema: + $ref: '#/components/schemas/MetricsListResponse' + + /api/metrics/query: + get: + summary: Query Health Metrics + parameters: + - name: metric_type + in: query + schema: + type: string + - name: start_date + in: query + schema: + type: string + format: date + - name: end_date + in: query + schema: + type: string + format: date + - name: limit + in: query + schema: + type: integer + default: 100 + responses: + '200': + description: A list of health metrics + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/HealthMetricResponse' + + /api/health-data/summary: + get: + summary: Get Health Data Summary + parameters: + - name: start_date + in: query + schema: + type: string + format: date + - name: end_date + in: query + schema: + type: string + format: date + responses: + '200': + description: A summary of health data + content: + application/json: + schema: + $ref: '#/components/schemas/HealthDataSummary' + +components: + schemas: + SyncResponse: + type: object + properties: + status: + type: string + message: + type: string + job_id: + type: string + + ActivityResponse: + type: object + properties: + id: + type: integer + garmin_activity_id: + type: string + activity_name: + type: string + activity_type: + type: string + start_time: + type: string + format: date-time + duration: + type: integer + file_type: + type: string + download_status: + type: string + downloaded_at: + type: string + format: date-time + + HealthMetricResponse: + type: object + properties: + id: + type: integer + metric_type: + type: string + metric_value: + type: number + unit: + type: string + timestamp: + type: string + format: date-time + date: + type: string + format: date + source: + type: string + detailed_data: + type: object + nullable: true + + MetricsListResponse: + type: object + properties: + metric_types: + type: array + items: + type: string + date_range: + type: object + properties: + start_date: + type: string + format: date + end_date: + type: string + format: date + + HealthDataSummary: + type: object + properties: + total_steps: + type: integer + avg_heart_rate: + type: number + total_sleep_hours: + type: number + avg_calories: + type: number \ No newline at end of file diff --git a/FitnessSync/specs/002-fitbit-garmin-sync/data-model.md b/FitnessSync/specs/002-fitbit-garmin-sync/data-model.md new file mode 100644 index 0000000..75406b6 --- /dev/null +++ b/FitnessSync/specs/002-fitbit-garmin-sync/data-model.md @@ -0,0 +1,65 @@ +# Data Model: Fitbit/Garmin Data Sync + +**Date**: 2025-12-24 + +This document describes the data models for the entities involved in the Fitbit/Garmin data sync feature. The models are based on the existing SQLAlchemy models in the project. + +## Activity + +Represents a single fitness activity (e.g., running, cycling). + +**SQLAlchemy Model**: `src/models/activity.py` + +| Field | Type | Description | +| ------------------ | ------------ | ----------------------------------------------------------- | +| `id` | Integer | Primary key. | +| `garmin_activity_id` | String | The original unique identifier from Garmin Connect. | +| `activity_name` | String | The name of the activity (e.g., "Afternoon Run"). | +| `activity_type` | String | The type of activity (e.g., 'running', 'cycling'). | +| `start_time` | DateTime | The time the activity started. | +| `duration` | Integer | The duration of the activity in seconds. | +| `file_content` | LargeBinary | The original activity file content (e.g., .fit, .gpx, .tcx).| +| `file_type` | String | The file type of the original activity file. | +| `download_status` | String | The status of the file download ('pending', 'downloaded', 'failed'). | +| `downloaded_at` | DateTime | The timestamp when the file was downloaded. | +| `created_at` | DateTime | The timestamp when the record was created. | +| `updated_at` | DateTime | The timestamp when the record was last updated. | + +## HealthMetric + +Represents a single health data point (e.g., steps, heart rate variability). + +**SQLAlchemy Model**: `src/models/health_metric.py` + +| Field | Type | Description | +| -------------- | -------- | ----------------------------------------------------- | +| `id` | Integer | Primary key. | +| `metric_type` | String | The type of metric (e.g., 'steps', 'heart_rate'). | +| `metric_value` | Float | The value of the metric. | +| `unit` | String | The unit of measurement (e.g., 'steps', 'ms'). | +| `timestamp` | DateTime | The timestamp when the metric was recorded. | +| `date` | DateTime | The date of the metric. | +| `source` | String | The source of the metric (e.g., 'garmin'). | +| `detailed_data`| Text | Additional details, stored as a JSON string. | +| `created_at` | DateTime | The timestamp when the record was created. | +| `updated_at` | DateTime | The timestamp when the record was last updated. | + +## SyncLog + +Records the status and results of each synchronization operation. + +**SQLAlchemy Model**: `src/models/sync_log.py` + +| Field | Type | Description | +| ------------------- | -------- | -------------------------------------------------------------------- | +| `id` | Integer | Primary key. | +| `operation` | String | The type of operation (e.g., 'metrics_download', 'activity_sync'). | +| `status` | String | The status of the operation ('started', 'completed', 'failed'). | +| `message` | Text | A status message or error details. | +| `start_time` | DateTime | The timestamp when the operation started. | +| `end_time` | DateTime | The timestamp when the operation completed. | +| `records_processed` | Integer | The number of records successfully processed. | +- `records_failed` | Integer | The number of records that failed to process. | +| `user_id` | Integer | A reference to the user for whom the sync was run (if applicable). | +| `created_at` | DateTime | The timestamp when the record was created. | +| `updated_at` | DateTime | The timestamp when the record was last updated. | \ No newline at end of file diff --git a/FitnessSync/specs/002-fitbit-garmin-sync/plan.md b/FitnessSync/specs/002-fitbit-garmin-sync/plan.md new file mode 100644 index 0000000..52d891e --- /dev/null +++ b/FitnessSync/specs/002-fitbit-garmin-sync/plan.md @@ -0,0 +1,77 @@ +# Implementation Plan: Fitbit/Garmin Data Sync + +**Branch**: `002-fitbit-garmin-sync` | **Date**: 2025-12-24 | **Spec**: [link](./spec.md) +**Input**: Feature specification from `specs/002-fitbit-garmin-sync/spec.md` + +## Summary + +This feature will implement synchronization of activities and health metrics from Garmin Connect to the application's database. The implementation will involve wiring up existing backend stub endpoints to a sync service that uses the `garth` library to fetch data from Garmin. The synced data will be stored in a PostgreSQL database and exposed through a series of new API endpoints for querying and retrieval. + +## Technical Context + +**Language/Version**: Python 3.11 +**Primary Dependencies**: FastAPI, Uvicorn, SQLAlchemy, Pydantic, garth +**Storage**: PostgreSQL +**Testing**: pytest, pytest-asyncio +**Target Platform**: Linux server (via Docker) +**Project Type**: Web application (backend) +**Performance Goals**: Standard web application performance, with API responses under 500ms p95. +**Constraints**: The solution should be implemented within the existing `backend` service. +**Scale/Scope**: The initial implementation will support syncing data for a single user. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **Test-First**: The implementation will follow a test-driven approach. For each endpoint and service function, a corresponding set of unit and/or integration tests will be written before the implementation. + +*VERDICT: The plan adheres to the core principles of the constitution.* + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-fitbit-garmin-sync/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── api-contract.yaml +└── tasks.md # Phase 2 output (created by /speckit.tasks) +``` + +### Source Code (repository root) + +The implementation will be contained within the existing `backend` directory structure. + +```text +backend/ +├── src/ +│ ├── api/ +│ │ ├── activities.py +│ │ ├── metrics.py +│ │ └── sync.py +│ ├── models/ +│ │ ├── activity.py +│ │ └── health_metric.py +│ └── services/ +│ ├── sync_app.py +│ └── garmin/ +│ └── client.py +└── tests/ + ├── integration/ + │ ├── test_sync_flow.py + └── unit/ + ├── test_api/ + │ ├── test_activities.py + │ └── test_metrics.py + └── test_services/ + └── test_sync_app.py +``` + +**Structure Decision**: The plan adheres to the existing project structure, which is a single backend service. New functionality will be added to the appropriate existing modules. + +## Complexity Tracking +*No constitutional violations to justify.* \ No newline at end of file diff --git a/FitnessSync/specs/002-fitbit-garmin-sync/quickstart.md b/FitnessSync/specs/002-fitbit-garmin-sync/quickstart.md new file mode 100644 index 0000000..67e4e9f --- /dev/null +++ b/FitnessSync/specs/002-fitbit-garmin-sync/quickstart.md @@ -0,0 +1,78 @@ +# Quickstart: Fitbit/Garmin Data Sync + +**Date**: 2025-12-24 + +This guide provides instructions on how to set up and run the application to test the Fitbit/Garmin data sync feature. + +## Prerequisites + +- Docker and Docker Compose +- Python 3.11 +- An active Garmin Connect account + +## Setup + +1. **Clone the repository**: + ```bash + git clone + cd + ``` + +2. **Set up environment variables**: + - Copy the `.env.example` file to `.env`. + - Fill in the required environment variables, including your Garmin Connect email and password. + ```bash + cp .env.example .env + # Edit .env with your credentials + ``` + +3. **Build and run the application**: + ```bash + docker-compose up --build + ``` + The application will be available at `http://localhost:8000`. + +## Testing the Endpoints + +You can use `curl` or any API client to test the new endpoints. + +### 1. Trigger Activity Sync + +```bash +curl -X POST http://localhost:8000/api/sync/activities \ +-H "Content-Type: application/json" \ +-d '{"days_back": 7}' +``` + +### 2. List Activities + +```bash +curl http://localhost:8000/api/activities/list +``` + +### 3. Trigger Health Metrics Sync + +```bash +curl -X POST http://localhost:8000/api/sync/metrics +``` + +### 4. Query Health Metrics + +```bash +curl "http://localhost:8000/api/metrics/query?metric_type=steps&limit=10" +``` + +### 5. Verify Data in Database + +You can connect to the PostgreSQL database to verify that the data has been synced correctly. + +```bash +docker exec -it psql -U postgres -d fitbit_garmin_sync +``` + +Then, run SQL queries to inspect the `activities` and `health_metrics` tables: + +```sql +SELECT * FROM activities; +SELECT * FROM health_metrics; +``` \ No newline at end of file diff --git a/FitnessSync/specs/002-fitbit-garmin-sync/research.md b/FitnessSync/specs/002-fitbit-garmin-sync/research.md new file mode 100644 index 0000000..073222f --- /dev/null +++ b/FitnessSync/specs/002-fitbit-garmin-sync/research.md @@ -0,0 +1,30 @@ +# Research: Fitbit/Garmin Data Sync + +**Date**: 2025-12-24 + +This document outlines the research performed for the Fitbit/Garmin data sync feature. + +## `garth` Library for Garmin Communication + +**Decision**: The `garth` library will be used for all communication with the Garmin Connect API. + +**Rationale**: +- The user's initial technical specification explicitly mentioned and provided examples using `garth`. +- The library appears to be actively maintained and provides the necessary functionality for fetching activities and health metrics. +- It handles the authentication flow with Garmin, which is a complex part of the integration. + +**Alternatives considered**: +- `garminconnect`: The user's `requirements.txt` includes this library, but the technical details provided in the prompt favored `garth`. `garth` seems to be a more modern library for interacting with the Garmin API. + +**Key Findings from User Prompt & `garth` Documentation:** +- The user's prompt provided detailed code snippets for using `garth` to fetch `DailySteps` and `DailyHRV`. This will serve as a strong starting point for the implementation. +- The `garth` library offers a variety of other metric types that can be explored for future enhancements (e.g., stress, sleep). +- Authentication is handled by the `garth.client.Garth` class, which needs to be configured with the user's credentials. The application already has a mechanism for storing API tokens, which will be used to store the Garmin credentials. + +## API Endpoint Design + +**Decision**: The API endpoints will be implemented as described in the user's initial technical specification. + +**Rationale**: The user provided a complete and well-defined set of API endpoints, including request/response models and URL paths. This design is consistent with a standard RESTful API and meets all the requirements of the feature. + +**Alternatives considered**: None. The user's specification was clear and complete. \ No newline at end of file diff --git a/FitnessSync/specs/002-fitbit-garmin-sync/spec.md b/FitnessSync/specs/002-fitbit-garmin-sync/spec.md new file mode 100644 index 0000000..8ba4293 --- /dev/null +++ b/FitnessSync/specs/002-fitbit-garmin-sync/spec.md @@ -0,0 +1,157 @@ +# Feature Specification: Fitbit/Garmin Data Sync + +**Feature Branch**: `002-fitbit-garmin-sync` +**Created**: 2025-12-24 +**Status**: Implemented + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Sync Activities from Garmin (Priority: P1) + +As a user, I want to trigger a synchronization of my recent activities from my Garmin account so that my fitness data is stored and available within the application. + +**Why this priority**: This is a core feature, enabling users to bring their primary workout data into the system. + +**Independent Test**: This can be tested by a user triggering a sync and verifying their recent activities appear in their activity list. + +**Acceptance Scenarios**: + +1. **Given** I have connected my Garmin account, **When** I trigger an activity sync, **Then** my activities from the last 30 days appear in my activity list. +2. **Given** my Garmin account is not connected, **When** I attempt to trigger a sync, **Then** I see a message instructing me to connect my account first. + +--- + +### User Story 2 - View and Query Activities (Priority: P2) + +As a user, I want to list my synced activities, filter them by criteria like activity type and date, and download the original activity file for my records. + +**Why this priority**: Allows users to find, analyze, and export their workout data. + +**Independent Test**: A user can navigate to the activity list, apply filters, and attempt to download a file. + +**Acceptance Scenarios**: + +1. **Given** I have synced activities, **When** I view the activity list, **Then** I see a paginated list of my activities, sorted by most recent first. +2. **Given** I have a list of activities, **When** I filter by "running" and a specific date range, **Then** I only see running activities within that range. +3. **Given** an activity has its original file available, **When** I click "Download", **Then** the file is downloaded to my device. + +--- + +### User Story 3 - Sync Health Metrics from Garmin (Priority: P1) + +As a user, I want to trigger a synchronization of my daily health metrics (like steps and HRV) from my Garmin account to track my wellness over time. + +**Why this priority**: Provides users with a holistic view of their health beyond just workouts. + +**Independent Test**: A user can trigger a metric sync and see updated data in their health dashboard or metric query views. + +**Acceptance Scenarios**: + +1. **Given** I have connected my Garmin account, **When** I trigger a health metric sync, **Then** my metrics from the last 30 days are updated. +2. **Given** data is synced, **When** I view my dashboard, **Then** I see my latest step count and HRV data. + +--- + +### User Story 4 - View and Query Health Metrics (Priority: P2) + +As a user, I want to see a list of all the metric types I've synced, query my data for specific time ranges, and view a high-level summary. + +**Why this priority**: Enables users to analyze trends and understand their health data. + +**Independent Test**: A user can navigate to the metrics section, select a metric type and date range, and view the resulting data and summary. + +**Acceptance Scenarios**: + +1. **Given** I have synced health metrics, **When** I visit the metrics page, **Then** I see a list of all available metric types (e.g., "Steps", "HRV"). +2. **Given** I have synced step data, **When** I query for "Steps" in the last week, **Then** I see a chart or list of my daily step counts for that period. +3. **Given** I have synced data, **When** I view the health summary for the last month, **Then** I see accurate totals and averages for my key metrics. + +### Edge Cases +- What happens if the Garmin service is unavailable during a sync? +- How does the system handle a user revoking access from the Garmin side? +- What should be displayed for a day with no data for a specific metric? +- How does the system handle a sync that is interrupted mid-way? + +## Requirements *(mandatory)* + +### UI/UX Improvements +- **UI-001**: The dashboard (`/`) MUST display current sync statistics (total activities, downloaded activities) and recent sync logs in a user-friendly table. +- **UI-002**: All user interactions (e.g., triggering syncs, fetching data) MUST provide feedback via non-blocking toast notifications instead of disruptive `alert()` popups. +- **UI-003**: The dashboard MUST provide clear buttons to trigger activity sync, health metrics sync, and navigate to setup/configuration and API documentation. +- **UI-004**: The dashboard MUST clearly display a link to the `/setup` page for authentication. + +### Authentication and Setup + +- **AS-001**: The system MUST provide an endpoint (`/setup/auth-status`) to check the current authentication status for Garmin. (Fitbit status is a placeholder). +- **AS-002**: Users MUST be able to initiate a connection to their Garmin account by providing their username and password via a `POST` to `/setup/garmin`. The system automatically configures the correct Garmin domain (`garmin.com` or `garmin.cn`) based on the `is_china` flag. +- **AS-003**: The system MUST handle Garmin's Multi-Factor Authentication (MFA) process. If MFA is required, the `/setup/garmin` endpoint will return `mfa_required` status, and the user must complete the process by sending the verification code to `/setup/garmin/mfa`. +- **AS-004**: The system MUST securely store the resulting `garth` authentication tokens (OAuth1 and OAuth2) and serializable MFA state in the database for future use, associated with the 'garmin' token type. +- **AS-005**: The system MUST provide an endpoint (`/setup/garmin/test-token`) to validate the stored Garmin authentication tokens by attempting to fetch user profile information via `garth.UserProfile.get()`. + +### Functional Requirements + +- **FR-001**: Users MUST be able to trigger a manual synchronization of their Garmin activities via a `POST` request to the `/api/sync/activities` endpoint. This endpoint accepts a `days_back` parameter to specify the sync range. +- **FR-002**: The system MUST fetch activity metadata from Garmin using `garth.client.connectapi("/activitylist-service/activities/search/activities", ...)` and store users' Garmin activities in the database, including `garmin_activity_id`, `activity_name`, `activity_type` (extracted from `activityType.typeKey`), `start_time`, `duration`, `file_type`, `download_status`, `downloaded_at`, and the binary `file_content`. +- **FR-003**: When downloading activity files, the system MUST attempt to download in preferred formats (`original`, `tcx`, `gpx`, `fit`) sequentially until a successful download is achieved. The `garth.client.download()` method MUST be used with a dynamically constructed path like `/download-service/export/{file_type}/activity/{activity_id}`. +- **FR-004**: Users MUST be able to view a paginated list of their synchronized activities via a `GET` request to the `/api/activities/list` endpoint. +- **FR-005**: Users MUST be able to filter their activities by `activity_type`, `start_date`, `end_date`, and `download_status` via a `GET` request to the `/api/activities/query` endpoint. +- **FR-006**: Users MUST be able to download the original data file for an individual activity via a `GET` request to the `/api/activities/download/{activity_id}` endpoint. +- **FR-007**: Users MUST be able to trigger a manual synchronization of their Garmin health metrics via a `POST` request to the `/api/sync/metrics` endpoint. This endpoint accepts a `days_back` parameter. +- **FR-008**: The system MUST fetch daily health metrics (steps, HRV, sleep) using `garth.stats.steps.DailySteps.list()`, `garth.stats.hrv.DailyHRV.list()`, and `garth.data.sleep.SleepData.list()` respectively. +- **FR-009**: The system MUST store users' Garmin health metrics in the database, including `metric_type` ('steps', 'hrv', 'sleep'), `metric_value`, `unit`, `timestamp`, `date`, and `source` ('garmin'). +- **FR-010**: Users MUST be able to see which types of health metrics are available for querying via a `GET` request to the `/api/metrics/list` endpoint. +- **FR-011**: Users MUST be able to query their health metrics by `metric_type`, `start_date`, and `end_date` via a `GET` request to the `/api/metrics/query` endpoint. +- **FR-012**: Users MUST be able to view a summary of their health data (e.g., totals, averages) over a specified time period via a `GET` request to the `/api/health-data/summary` endpoint. +- **FR-013**: The system MUST provide clear feedback on the status of a synchronization. This includes a summary of sync status available via the `/api/status` endpoint and a detailed list of synchronization logs available via the `/api/logs` endpoint. +- **FR-014**: The system MUST handle synchronization failures gracefully and log errors for troubleshooting. +- **FR-015**: Users MUST be able to trigger a manual synchronization of their weight data via a `POST` request to the `/api/sync/weight` endpoint. (Note: Actual implementation for weight sync is currently a placeholder). + +### Key Entities + +- **Activity**: Represents a single fitness activity. + - `id`: Unique identifier in the database. + - `garmin_activity_id`: The ID from Garmin. + - `activity_name`: User-defined name of the activity. + - `activity_type`: Type of activity (e.g., 'running', 'cycling'), extracted from `activityType.typeKey`. + - `start_time`: The start time of the activity. + - `duration`: Duration of the activity in seconds. + - `file_type`: The format of the downloaded file ('tcx', 'gpx', 'fit', or 'original'). + - `file_content`: The binary content of the activity file. + - `download_status`: The status of the file download from Garmin ('pending', 'downloaded', 'failed'). + - `downloaded_at`: Timestamp when the file was downloaded. +- **Health Metric**: Represents a single health data point for a specific day. + - `id`: Unique identifier in the database. + - `metric_type`: The type of metric (e.g., 'steps', 'hrv', 'sleep'). + - `metric_value`: The value of the metric. + - `unit`: The unit of measurement. + - `timestamp`: The precise timestamp of the metric. + - `date`: The date the metric was recorded. + - `source`: The source of the data (e.g., 'garmin'). +- **Sync Log**: Records the outcome of each synchronization attempt. + - `id`: Unique identifier in the database. + - `operation`: The type of sync operation ('activity_sync', 'health_metric_sync', 'weight_sync'). + - `status`: The outcome ('started', 'completed', 'completed_with_errors', 'failed'). + - `message`: A descriptive message about the outcome. + - `start_time`: When the sync began. + - `end_time`: When the sync completed. + - `records_processed`: Number of records successfully processed. + - `records_failed`: Number of records that failed. +- **APIToken**: Stores authentication tokens for third-party services. + - `id`: Unique identifier in the database. + - `token_type`: The service the token is for ('garmin', 'fitbit'). + - `garth_oauth1_token`: The OAuth1 token for Garmin (JSON string). + - `garth_oauth2_token`: The OAuth2 token for Garmin (JSON string). + - `mfa_state`: Stores serializable MFA state (JSON string) if MFA is required during login. + - `mfa_expires_at`: Timestamp indicating when the MFA state expires. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 95% of users can successfully sync their last 30 days of activities from Garmin on the first attempt. +- **SC-002**: 100% of successfully synced activities appear in the user's activity list within 1 minute of sync completion. +- **SC-003**: 99% of activity list filtering operations return accurate results in under 2 seconds. +- **SC-004**: Users can successfully download the original data file for any activity where one is present. +- **SC-005**: 95% of users can successfully sync their last 30 days of health metrics from Garmin, including steps and HRV. +- **SC-006**: The health data summary for a 30-day period is calculated and displayed in under 3 seconds. +- **SC-007**: The system provides a user-friendly error message within 10 seconds if a Garmin sync fails due to authentication or service availability issues. \ No newline at end of file diff --git a/FitnessSync/specs/002-fitbit-garmin-sync/tasks.md b/FitnessSync/specs/002-fitbit-garmin-sync/tasks.md new file mode 100644 index 0000000..3a52cf8 --- /dev/null +++ b/FitnessSync/specs/002-fitbit-garmin-sync/tasks.md @@ -0,0 +1,215 @@ +# Tasks: Fitbit/Garmin Data Sync + +**Input**: Design documents from `specs/002-fitbit-garmin-sync/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Tests are included based on the TDD principle mentioned in the constitution. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- Paths shown below assume single project - adjust based on plan.md structure + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [ ] T001 Review `sync_app.py` in `backend/src/services/sync_app.py` +- [ ] T002 Review `garmin/client.py` in `backend/src/services/garmin/client.py` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [ ] T003 Add error handling to all endpoints in `backend/src/api/` +- [ ] T004 Add logging at key points in `backend/src/api/` and `backend/src/services/` +- [ ] T005 Add missing imports for API Token, SyncApp, GarminClient, setup_logger to `backend/src/api/sync.py` +- [ ] T006 Add missing imports for Response, Activity to `backend/src/api/activities.py` +- [ ] T007 Add missing imports for func, HealthMetric to `backend/src/api/metrics.py` + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - Sync Activities from Garmin (Priority: P1) 🎯 MVP + +**Goal**: Users can trigger a synchronization of their recent activities from their Garmin account so that their fitness data is stored and available within the application. + +**Independent Test**: Trigger a sync and verify recent activities appear in the activity list. Also, verify error message when Garmin account is not connected. + +### Tests for User Story 1 + +- [ ] T008 [US1] Write unit tests for `POST /api/sync/activities` endpoint in `backend/tests/unit/test_api/test_sync.py` +- [ ] T009 [US1] Write integration tests for activity sync flow in `backend/tests/integration/test_sync_flow.py` + +### Implementation for User Story 1 + +- [ ] T010 [US1] Implement activity sync logic in `backend/src/api/sync.py` (replace mock response with actual sync logic) +- [ ] T011 [US1] Implement `sync_activities` method in `backend/src/services/sync_app.py` to use `GarminClient` + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 3 - Sync Health Metrics from Garmin (Priority: P1) + +**Goal**: Users can trigger a synchronization of their daily health metrics (like steps and HRV) from their Garmin account to track their wellness over time. + +**Independent Test**: Trigger a metric sync and see updated data in their health dashboard or metric query views. + +### Tests for User Story 3 + +- [ ] T012 [US3] Write unit tests for `POST /api/sync/metrics` endpoint in `backend/tests/unit/test_api/test_sync.py` +- [ ] T013 [US3] Write integration tests for health metrics sync flow in `backend/tests/integration/test_sync_flow.py` + +### Implementation for User Story 3 + +- [ ] T014 [US3] Create `POST /api/sync/metrics` endpoint in `backend/src/api/sync.py` +- [ ] T015 [US3] Implement `sync_health_metrics` method in `backend/src/services/sync_app.py` to use `garth` library + +**Checkpoint**: At this point, User Stories 1 AND 3 should both work independently + +--- + +## Phase 5: User Story 2 - View and Query Activities (Priority: P2) + +**Goal**: Users can list their synced activities, filter them by criteria like activity type and date, and download the original activity file for their records. + +**Independent Test**: Navigate to the activity list, apply filters, and attempt to download a file. + +### Tests for User Story 2 + +- [ ] T016 [US2] Write unit tests for `GET /api/activities/list` endpoint in `backend/tests/unit/test_api/test_activities.py` +- [ ] T017 [US2] Write unit tests for `GET /api/activities/query` endpoint in `backend/tests/unit/test_api/test_activities.py` +- [ ] T018 [US2] Write unit tests for `GET /api/activities/download/{activity_id}` endpoint in `backend/tests/unit/test_api/test_activities.py` + +### Implementation for User Story 2 + +- [ ] T019 [US2] Implement `list_activities` endpoint logic in `backend/src/api/activities.py` +- [ ] T020 [US2] Implement `query_activities` endpoint logic in `backend/src/api/activities.py` +- [ ] T021 [US2] Implement `download_activity` endpoint logic in `backend/src/api/activities.py` + +**Checkpoint**: At this point, User Stories 1, 3 and 2 should all work independently + +--- + +## Phase 6: User Story 4 - View and Query Health Metrics (Priority: P2) + +**Goal**: Users can see a list of all the metric types they've synced, query their data for specific time ranges, and view a high-level summary. + +**Independent Test**: Navigate to the metrics section, select a metric type and date range, and view the resulting data and summary. + +### Tests for User Story 4 + +- [ ] T022 [US4] Write unit tests for `GET /api/metrics/list` endpoint in `backend/tests/unit/test_api/test_metrics.py` +- [ ] T023 [US4] Write unit tests for `GET /api/metrics/query` endpoint in `backend/tests/unit/test_api/test_metrics.py` +- [ ] T024 [US4] Write unit tests for `GET /api/health-data/summary` endpoint in `backend/tests/unit/test_api/test_metrics.py` + +### Implementation for User Story 4 + +- [ ] T025 [US4] Implement `list_available_metrics` endpoint logic in `backend/src/api/metrics.py` +- [ ] T026 [US4] Implement `query_metrics` endpoint logic in `backend/src/api/metrics.py` +- [ ] T027 [US4] Implement `get_health_summary` endpoint logic in `backend/src/api/metrics.py` + +**Checkpoint**: All user stories should now be independently functional + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] T028 Review error handling across all new endpoints in `backend/src/api/` +- [ ] T029 Review logging implementation across `backend/src/api/` and `backend/src/services/` +- [ ] T030 Add/update documentation for new API endpoints (e.g., OpenAPI spec, inline comments) in `specs/002-fitbit-garmin-sync/contracts/api-contract.yaml` and relevant Python files. +- [ ] T031 Run quickstart.md validation as described in `specs/002-fitbit-garmin-sync/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P1 → P2 → P2) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 3 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable +- **User Story 4 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1/US3/US2 but should be independently testable + +### Within Each User Story + +- Tests MUST be written and FAIL before implementation +- Models before services (where applicable) +- Services before endpoints (where applicable) +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Setup tasks can run in parallel. +- All Foundational tasks can run in parallel (within Phase 2). +- Once Foundational phase completes, user stories can start in parallel by different team members. +- Tests for a user story can run in parallel. +- Implementation tasks within a user story can be identified for parallel execution where there are no dependencies. + +## Implementation Strategy + +### MVP First (User Stories 1 & 3) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. Complete Phase 4: User Story 3 +5. **STOP and VALIDATE**: Test User Stories 1 and 3 independently. +6. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 3 → Test independently → Deploy/Demo +4. Add User Story 2 → Test independently → Deploy/Demo +5. Add User Story 4 → Test independently → Deploy/Demo +6. Each story adds value without breaking previous stories + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 3 + - Developer C: User Story 2 + - Developer D: User Story 4 +3. Stories complete and integrate independently + +--- + +## Notes + +- Tasks with file paths indicate specific files to be created or modified. +- Each user story should be independently completable and testable. +- Verify tests fail before implementing. +- Commit after each task or logical group. +- Stop at any checkpoint to validate story independently. +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence