Add sync progress bar; change auto-sync to every 30 minutes
Backend:
- Change beat schedule from 3600s (hourly) to 1800s (30 minutes)
- Emit intermediate last_sync_status DB commits at each phase of
sync_garmin_connect_user ("Connecting to Garmin...", "Syncing activities...",
"Syncing wellness data...") so the frontend can reflect live progress.
Snapshot config fields upfront to avoid reading expired ORM attrs after commits.
Frontend (ProfilePage):
- Replace blind 3-second timeout with 2s polling loop that reads the live
last_sync_status from /garmin-sync/config after triggering a sync.
- Wait until an in-progress status is observed before declaring completion,
avoiding a false-finish on the previous terminal status.
- Show an animated progress bar that advances through the sync phases with
the current status text below it. Safety timeout stops polling after 10 min.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gcSyncing && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all duration-700"
|
||||
style={{ width: `${syncProgressPct(garminConfig?.last_sync_status)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-blue-400">
|
||||
{garminConfig?.last_sync_status || 'Starting sync…'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* PocketID — admin only */}
|
||||
|
||||
Reference in New Issue
Block a user