Fix Garmin Connect sync to import full history and prevent re-downloads
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 54s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 22s

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:
2026-06-07 00:33:49 +01:00
parent 7d6d34f61f
commit 335bd0a053
2 changed files with 43 additions and 8 deletions
+38 -8
View File
@@ -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