diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py index a1f9bb6..04c3e05 100644 --- a/backend/app/services/garmin_connect_sync.py +++ b/backend/app/services/garmin_connect_sync.py @@ -69,15 +69,20 @@ def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str def sync_activities(garmin, user_id: int, since: Optional[datetime], db, file_store_path: str) -> int: """ - List activities since `since` from Garmin Connect, skip any already in the - DB, download FIT ZIPs for new ones, and queue them for processing. + List activities from Garmin Connect, skip any already in the DB, download + FIT ZIPs for new ones, and queue them for processing. + + On first sync (since=None) fetches the full account history back to 2010. + On incremental syncs fetches from one day before last_sync_at. Returns the number of new activities queued. """ + import time from app.workers.tasks import process_activity_file from app.models.user import Activity - from sqlalchemy import select + from sqlalchemy import select, func - start_date = (since - timedelta(days=1)).date() if since else (date.today() - timedelta(days=30)) + # First sync: fetch everything; incremental: one day overlap to catch late uploads + start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1) end_date = date.today() try: @@ -94,14 +99,35 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime], if not garmin_id: continue - # Skip if already imported (garmin_activity_id unique index) + # Fast path: already imported via Garmin Connect sync existing = db.execute( select(Activity).where(Activity.garmin_activity_id == garmin_id) ).scalar_one_or_none() if existing: continue - # Download original FIT (wrapped in a ZIP by Garmin) + # Slow-path dedup: activity imported via bulk export (no garmin_activity_id). + # Check by start_time; stamp the ID so future syncs skip it in the fast path. + act_start_str = act.get("startTimeLocal") or act.get("startTimeGMT") or "" + if act_start_str: + try: + from datetime import datetime as _dt + act_start = _dt.fromisoformat(act_start_str.replace("Z", "+00:00")) + time_match = db.execute( + select(Activity).where( + Activity.user_id == user_id, + func.date(Activity.start_time) == act_start.date(), + ) + ).scalar_one_or_none() + if time_match: + if not time_match.garmin_activity_id: + time_match.garmin_activity_id = garmin_id + db.commit() + continue + except Exception: + pass # couldn't parse time — fall through to download + + # Download original FIT (Garmin wraps it in a ZIP) try: zip_bytes = garmin.download_activity( int(garmin_id), @@ -111,7 +137,7 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime], logger.warning("Failed to download activity %s: %s", garmin_id, exc) continue - # Extract the FIT file from the ZIP + # Extract the FIT from the ZIP try: with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")] @@ -132,6 +158,9 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime], process_activity_file.delay(str(dest), user_id, "fit", garmin_id) queued += 1 + # Brief pause to avoid hammering the Garmin API + time.sleep(0.5) + return queued @@ -145,7 +174,8 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db) -> int: """ from sqlalchemy import text - start_date = since.date() if since else (date.today() - timedelta(days=7)) + # First sync: 90 days of wellness history; incremental: from last sync + start_date = since.date() if since else (date.today() - timedelta(days=90)) days = (date.today() - start_date).days + 1 processed = 0 diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index afc0ae9..da33b71 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -93,6 +93,11 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str, ).scalar_one_or_none() if existing: + # Stamp garmin_activity_id if this came from a Garmin Connect sync + # so future syncs skip the fast-path dedup and don't re-download. + if garmin_activity_id and not existing.garmin_activity_id: + existing.garmin_activity_id = garmin_activity_id + db.commit() return {"activity_id": existing.id, "status": "duplicate"} # Get user max HR for zone calculation