diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 06cc37e..c23b471 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -23,9 +23,9 @@ celery_app.conf.update( task_track_started=True, worker_prefetch_multiplier=1, beat_schedule={ - "sync-garmin-connect-hourly": { + "sync-garmin-connect": { "task": "sync_all_garmin_connect", - "schedule": 3600.0, # every hour + "schedule": 1800.0, # every 30 minutes }, }, ) @@ -481,8 +481,20 @@ def sync_garmin_connect_user(user_id: int): if not cfg or not cfg.sync_enabled: return {"status": "skipped"} + # Snapshot config values before any intermediate commits (commits expire ORM attrs) + email = cfg.email + password_enc = cfg.password_enc + token_store = cfg.token_store + last_sync_at = cfg.last_sync_at + sync_acts = cfg.sync_activities + sync_well = cfg.sync_wellness + lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30 + + cfg.last_sync_status = "Connecting to Garmin..." + db.commit() + try: - garmin, new_token = authenticate_garmin(cfg.email, cfg.password_enc, cfg.token_store) + garmin, new_token = authenticate_garmin(email, password_enc, token_store) except Exception as exc: cfg.last_sync_at = datetime.now(timezone.utc) cfg.last_sync_status = f"Auth error: {exc}" @@ -491,25 +503,30 @@ def sync_garmin_connect_user(user_id: int): if new_token: cfg.token_store = new_token + db.commit() activities_queued = 0 wellness_days = 0 errors = [] - if cfg.sync_activities: + if sync_acts: + cfg.last_sync_status = "Syncing activities..." + db.commit() try: activities_queued = sync_activities( - garmin, user_id, cfg.last_sync_at, db, settings.file_store_path, - lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30, + garmin, user_id, last_sync_at, db, settings.file_store_path, + lookback_days=lookback, ) except Exception as exc: errors.append(f"activities: {exc}") - if cfg.sync_wellness: + if sync_well: + cfg.last_sync_status = "Syncing wellness data..." + db.commit() try: wellness_days = sync_wellness( - garmin, user_id, cfg.last_sync_at, db, - lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 90, + garmin, user_id, last_sync_at, db, + lookback_days=lookback, ) except Exception as exc: errors.append(f"wellness: {exc}") diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index d40e22f..ac9e824 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -119,6 +119,8 @@ export default function ProfilePage() { const [gcSaved, setGcSaved] = useState(false) const [gcError, setGcError] = useState('') const [gcSyncing, setGcSyncing] = useState(false) + const syncPollRef = useRef(null) + useEffect(() => () => { if (syncPollRef.current) clearInterval(syncPollRef.current) }, []) useEffect(() => { if (garminConfig?.connected) { setGcForm(f => ({ @@ -153,12 +155,37 @@ export default function ProfilePage() { setGcSyncing(true) try { await api.post('/garmin-sync/trigger') - setTimeout(() => { refetchGarmin(); setGcSyncing(false) }, 3000) - } catch (e) { + // Poll every 2s: wait until we've seen an in-progress status, then wait for terminal + let seenInProgress = false + syncPollRef.current = setInterval(async () => { + const result = await refetchGarmin() + const status = result.data?.last_sync_status ?? '' + const terminal = status.startsWith('OK') || status.startsWith('Partial') || status.startsWith('Auth error') + if (!terminal) seenInProgress = true + if (seenInProgress && terminal) { + clearInterval(syncPollRef.current) + syncPollRef.current = null + setGcSyncing(false) + } + }, 2000) + // Safety: stop polling after 10 minutes regardless + setTimeout(() => { + if (syncPollRef.current) { clearInterval(syncPollRef.current); syncPollRef.current = null } + setGcSyncing(false) + }, 600000) + } catch { setGcSyncing(false) } } + const syncProgressPct = status => { + if (!status) return 5 + if (status.startsWith('Connecting')) return 10 + if (status.startsWith('Syncing activities')) return 35 + if (status.startsWith('Syncing wellness')) return 70 + return 5 + } + // PocketID config const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' }) const [pidSaved, setPidSaved] = useState(false) @@ -389,6 +416,20 @@ export default function ProfilePage() { > )} + + {gcSyncing && ( +
+ {garminConfig?.last_sync_status || 'Starting sync…'} +
+