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],
|
def sync_activities(garmin, user_id: int, since: Optional[datetime],
|
||||||
db, file_store_path: str) -> int:
|
db, file_store_path: str) -> int:
|
||||||
"""
|
"""
|
||||||
List activities since `since` from Garmin Connect, skip any already in the
|
List activities from Garmin Connect, skip any already in the DB, download
|
||||||
DB, download FIT ZIPs for new ones, and queue them for processing.
|
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.
|
Returns the number of new activities queued.
|
||||||
"""
|
"""
|
||||||
|
import time
|
||||||
from app.workers.tasks import process_activity_file
|
from app.workers.tasks import process_activity_file
|
||||||
from app.models.user import Activity
|
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()
|
end_date = date.today()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -94,14 +99,35 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
|
|||||||
if not garmin_id:
|
if not garmin_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip if already imported (garmin_activity_id unique index)
|
# Fast path: already imported via Garmin Connect sync
|
||||||
existing = db.execute(
|
existing = db.execute(
|
||||||
select(Activity).where(Activity.garmin_activity_id == garmin_id)
|
select(Activity).where(Activity.garmin_activity_id == garmin_id)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if existing:
|
if existing:
|
||||||
continue
|
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:
|
try:
|
||||||
zip_bytes = garmin.download_activity(
|
zip_bytes = garmin.download_activity(
|
||||||
int(garmin_id),
|
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)
|
logger.warning("Failed to download activity %s: %s", garmin_id, exc)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract the FIT file from the ZIP
|
# Extract the FIT from the ZIP
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||||
fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
|
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)
|
process_activity_file.delay(str(dest), user_id, "fit", garmin_id)
|
||||||
queued += 1
|
queued += 1
|
||||||
|
|
||||||
|
# Brief pause to avoid hammering the Garmin API
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
return queued
|
return queued
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +174,8 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db) -> int:
|
|||||||
"""
|
"""
|
||||||
from sqlalchemy import text
|
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
|
days = (date.today() - start_date).days + 1
|
||||||
processed = 0
|
processed = 0
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
if existing:
|
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"}
|
return {"activity_id": existing.id, "status": "duplicate"}
|
||||||
|
|
||||||
# Get user max HR for zone calculation
|
# Get user max HR for zone calculation
|
||||||
|
|||||||
Reference in New Issue
Block a user