Fix Garmin Connect sync to import full history and prevent re-downloads
Activity sync: - First sync (no last_sync_at) now fetches from 2010-01-01 instead of -30 days, importing the full account history rather than only the last month - Pre-download dedup: check existing activities by start_time before downloading; stamps garmin_activity_id on the match so subsequent syncs take the fast path - process_activity_file stamps garmin_activity_id on duplicate detection for the same reason (covers activities imported via bulk export) - 0.5 s sleep between downloads to avoid Garmin API rate limiting Wellness sync: - First sync now covers last 90 days instead of 7 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user