Fixed Garmin sync progress bar granularity, timeout issue, and lookback days input, plus redesigned the sleep timeline with taller bars and yellow Awake colour.
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 48s
Build and push images / build-worker (push) Successful in 44s
Build and push images / build-frontend (push) Successful in 28s

This commit is contained in:
2026-06-07 18:15:07 +01:00
parent bf1920eb9d
commit 492418586a
5 changed files with 120 additions and 59 deletions
+26 -21
View File
@@ -169,7 +169,7 @@ function SleepTimeline({ sleepStart, sleepEnd, deep, light, rem, awake }) {
{ key: 'deep', secs: deep || 0, color: '#6366f1', label: 'Deep' },
{ key: 'rem', secs: rem || 0, color: '#8b5cf6', label: 'REM' },
{ key: 'light', secs: light || 0, color: '#a78bfa', label: 'Light' },
{ key: 'awake', secs: awake || 0, color: '#374151', label: 'Awake' },
{ key: 'awake', secs: awake || 0, color: '#eab308', label: 'Awake' },
].filter(s => s.secs > 0)
// Generate hour tick marks within the sleep window
@@ -185,27 +185,32 @@ function SleepTimeline({ sleepStart, sleepEnd, deep, light, rem, awake }) {
}
return (
<div className="space-y-1.5">
{/* Time bar */}
<div className="relative">
<div className="flex rounded-md overflow-hidden h-5 w-full">
{stages.map((s, i) => (
<div
key={s.key}
style={{ width: `${(s.secs / stageSecs * 100).toFixed(2)}%`, backgroundColor: s.color }}
/>
))}
</div>
{/* Tick marks */}
<div>
{/* Stage bars rising from the time axis */}
<div className="relative flex overflow-hidden rounded-t-sm" style={{ height: 48 }}>
{stages.map(s => (
<div
key={s.key}
title={`${s.label}: ${Math.round(s.secs / 60)} min`}
style={{ width: `${(s.secs / stageSecs * 100).toFixed(2)}%`, backgroundColor: s.color }}
/>
))}
{/* Tick lines overlaid on bars */}
{ticks.map((t, i) => (
<div key={i} className="absolute top-0 h-5 flex flex-col items-center pointer-events-none" style={{ left: `${t.pct}%` }}>
<div className="w-px h-full bg-black/40" />
</div>
<div key={i} className="absolute top-0 bottom-0 w-px bg-black/25 pointer-events-none"
style={{ left: `${t.pct}%` }} />
))}
</div>
{/* Axis line */}
<div className="border-t border-gray-600 relative">
{ticks.map((t, i) => (
<div key={i} className="absolute top-0 w-px h-1.5 bg-gray-600"
style={{ left: `${t.pct}%` }} />
))}
</div>
{/* Time labels */}
<div className="relative h-4">
<span className="absolute left-0 text-xs text-gray-500" style={{ transform: 'translateX(-0%)' }}>
<div className="relative h-4 mt-1">
<span className="absolute left-0 text-xs text-gray-500">
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
</span>
{ticks.map((t, i) => (
@@ -318,7 +323,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
['Deep', day.sleep_deep_s, '#6366f1'],
['REM', day.sleep_rem_s, '#8b5cf6'],
['Light', day.sleep_light_s, '#a78bfa'],
['Awake', day.sleep_awake_s, '#4b5563'],
['Awake', day.sleep_awake_s, '#eab308'],
].map(([label, secs, color]) => secs ? (
<div key={label} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
@@ -565,7 +570,7 @@ function SleepChart({ data, selectedDate, onDayClick }) {
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
<Bar dataKey="awake" name="Awake" stackId="a" fill="#eab308" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)
@@ -704,7 +709,7 @@ export default function HealthPage() {
<SleepChart data={metrics}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
<div className="flex gap-4 mt-2">
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
<div key={l} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
<span className="text-xs text-gray-400">{l}</span>
+59 -27
View File
@@ -115,7 +115,7 @@ export default function ProfilePage() {
queryKey: ['garmin-config'],
queryFn: () => api.get('/garmin-sync/config').then(r => r.data),
})
const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: 30 })
const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
const [gcSaved, setGcSaved] = useState(false)
const [gcError, setGcError] = useState('')
const [gcSyncing, setGcSyncing] = useState(false)
@@ -129,7 +129,7 @@ export default function ProfilePage() {
sync_enabled: garminConfig.sync_enabled,
sync_activities: garminConfig.sync_activities,
sync_wellness: garminConfig.sync_wellness,
sync_lookback_days: garminConfig.sync_lookback_days ?? 30,
sync_lookback_days: String(garminConfig.sync_lookback_days ?? 30),
}))
}
}, [garminConfig])
@@ -148,14 +148,14 @@ export default function ProfilePage() {
mutationFn: () => api.delete('/garmin-sync/config'),
onSuccess: () => {
refetchGarmin()
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: 30 })
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
},
})
const triggerGarminSync = async () => {
setGcSyncing(true)
try {
await api.post('/garmin-sync/trigger')
// Poll every 2s: wait until we've seen an in-progress status, then wait for terminal
// Poll every 3s: wait until we've seen an in-progress status, then wait for terminal
let seenInProgress = false
syncPollRef.current = setInterval(async () => {
const result = await refetchGarmin()
@@ -167,23 +167,36 @@ export default function ProfilePage() {
syncPollRef.current = null
setGcSyncing(false)
}
}, 2000)
// Safety: stop polling after 10 minutes regardless
}, 3000)
// Absolute safety: stop polling after 4 hours but keep bar visible — sync may still be running
setTimeout(() => {
if (syncPollRef.current) { clearInterval(syncPollRef.current); syncPollRef.current = null }
setGcSyncing(false)
}, 600000)
}, 4 * 60 * 60 * 1000)
} catch {
setGcSyncing(false)
}
}
const syncProgressPct = status => {
if (!status) return 5
if (!status) return 3
if (status.startsWith('Connecting')) return 10
if (status.startsWith('Syncing activities')) return 35
if (status.startsWith('Syncing wellness')) return 70
return 5
if (status.startsWith('Syncing activities')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m) {
const done = parseInt(m[1], 10), total = parseInt(m[2], 10)
if (total > 0) return 15 + Math.round(done / total * 30)
}
return 20
}
if (status.startsWith('Syncing wellness')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m) {
const done = parseInt(m[1], 10), total = parseInt(m[2], 10)
if (total > 0) return 45 + Math.round(done / total * 45)
}
return 50
}
return 3
}
// PocketID config
@@ -344,8 +357,8 @@ export default function ProfilePage() {
<Field label="Sync lookback days" hint="-1 syncs all available history (back to 2010). Leave at 30 for incremental syncs.">
<Input type="number" value={gcForm.sync_lookback_days} min={-1}
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: parseInt(e.target.value, 10) || 30 }))} />
{gcForm.sync_lookback_days > 365 && gcForm.sync_lookback_days !== -1 && (
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
{(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && (
<p className="text-yellow-400 text-xs mt-1">Warning: syncing more than 365 days at once may take a long time and could trigger Garmin rate limits.</p>
)}
</Field>
@@ -360,7 +373,10 @@ export default function ProfilePage() {
setGcError('Password is required for first-time setup')
return
}
const payload = { ...gcForm }
const payload = {
...gcForm,
sync_lookback_days: parseInt(gcForm.sync_lookback_days, 10) || 30,
}
if (!payload.password) delete payload.password
saveGarmin.mutate(payload)
}}
@@ -385,19 +401,35 @@ 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)}%` }}
/>
{gcSyncing && (() => {
const status = garminConfig?.last_sync_status || ''
const pct = syncProgressPct(status)
const phase = status.startsWith('Connecting') ? 0
: status.startsWith('Syncing activities') ? 1
: status.startsWith('Syncing wellness') ? 2
: status.startsWith('OK') || status.startsWith('Partial') ? 3 : -1
return (
<div className="space-y-2 pt-1">
<div className="flex items-center gap-1 text-xs">
{[['Connect', 0], ['Activities', 1], ['Wellness', 2]].map(([label, idx]) => (
<span key={label} className={`flex items-center gap-1 ${phase >= idx ? 'text-blue-400' : 'text-gray-600'}`}>
{idx > 0 && <span className="text-gray-700"></span>}
{label}
</span>
))}
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-xs text-blue-400">
{status || 'Starting sync…'}
</p>
</div>
<p className="text-xs text-blue-400">
{garminConfig?.last_sync_status || 'Starting sync…'}
</p>
</div>
)}
)
})()}
</Section>
{/* PocketID — admin only */}