Add sync progress bar; change auto-sync to every 30 minutes
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 16s

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:
2026-06-07 10:36:15 +01:00
parent a9b3da858d
commit a28ce0e009
2 changed files with 69 additions and 11 deletions
+43 -2
View File
@@ -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 */}