Add Garmin Connect auto-sync via python-garminconnect
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 8s

- GarminConnectConfig model stores encrypted credentials and OAuth token
- garmin_connect_sync service: token-based auth with password fallback,
  activity FIT download + queue, daily wellness from JSON API
- Celery beat schedule: sync_all_garmin_connect fires every hour
- New API router /api/garmin-sync: config CRUD, manual trigger
- Beat container added to docker-compose.yml and docker-compose.deploy.yml
- ProfilePage: Garmin Connect section with connect/update/disconnect and Sync now

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 00:08:12 +01:00
parent 0cdc653664
commit 6d224d51c5
9 changed files with 691 additions and 3 deletions
+134
View File
@@ -110,6 +110,54 @@ export default function ProfilePage() {
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
})
// Garmin Connect sync
const { data: garminConfig, refetch: refetchGarmin } = useQuery({
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 })
const [gcSaved, setGcSaved] = useState(false)
const [gcError, setGcError] = useState('')
const [gcSyncing, setGcSyncing] = useState(false)
useEffect(() => {
if (garminConfig?.connected) {
setGcForm(f => ({
...f,
email: garminConfig.email || '',
sync_enabled: garminConfig.sync_enabled,
sync_activities: garminConfig.sync_activities,
sync_wellness: garminConfig.sync_wellness,
}))
}
}, [garminConfig])
const saveGarmin = useMutation({
mutationFn: data => api.put('/garmin-sync/config', data).then(r => r.data),
onSuccess: () => {
refetchGarmin()
setGcSaved(true)
setGcError('')
setGcForm(f => ({ ...f, password: '' }))
setTimeout(() => setGcSaved(false), 3000)
},
onError: e => setGcError(e.response?.data?.detail || 'Failed to save'),
})
const deleteGarmin = useMutation({
mutationFn: () => api.delete('/garmin-sync/config'),
onSuccess: () => {
refetchGarmin()
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true })
},
})
const triggerGarminSync = async () => {
setGcSyncing(true)
try {
await api.post('/garmin-sync/trigger')
setTimeout(() => { refetchGarmin(); setGcSyncing(false) }, 3000)
} catch (e) {
setGcSyncing(false)
}
}
// PocketID config
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
const [pidSaved, setPidSaved] = useState(false)
@@ -248,6 +296,92 @@ export default function ProfilePage() {
/>
</Section>
{/* Garmin Connect Sync */}
<Section title="⌚ Garmin Connect Sync">
<p className="text-xs text-gray-500">
Connect your Garmin account to automatically import new activities and wellness data every hour.
Credentials are encrypted at rest.
</p>
{garminConfig?.connected && (
<div className="flex items-center justify-between bg-green-900/20 border border-green-800/40 rounded-lg px-3 py-2 text-xs">
<span className="text-green-400"> Connected as {garminConfig.email}</span>
<div className="flex items-center gap-3">
{garminConfig.last_sync_at && (
<span className="text-gray-500">
Last sync: {new Date(garminConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
)}
{garminConfig.last_sync_status && (
<span className={garminConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : garminConfig.last_sync_status.startsWith('Auth') ? 'text-red-400' : 'text-yellow-400'}>
{garminConfig.last_sync_status}
</span>
)}
</div>
</div>
)}
<div className="space-y-3">
<Field label="Garmin Connect email">
<Input value={gcForm.email} placeholder="you@example.com"
onChange={e => setGcForm(f => ({ ...f, email: e.target.value }))} />
</Field>
<Field label={garminConfig?.connected ? 'Password (leave blank to keep existing)' : 'Password'}>
<Input type="password" value={gcForm.password} placeholder="••••••••"
onChange={e => setGcForm(f => ({ ...f, password: e.target.value }))} />
</Field>
<div className="flex flex-wrap gap-4 pt-1">
{[
['sync_enabled', 'Enable hourly sync'],
['sync_activities', 'Sync activities (FIT download)'],
['sync_wellness', 'Sync wellness data'],
].map(([key, label]) => (
<label key={key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={gcForm[key]}
onChange={e => setGcForm(f => ({ ...f, [key]: e.target.checked }))}
className="w-4 h-4 accent-blue-500" />
<span className="text-sm text-gray-300">{label}</span>
</label>
))}
</div>
</div>
{gcError && <p className="text-red-400 text-xs">{gcError}</p>}
<div className="flex items-center gap-3 flex-wrap pt-1">
<SaveButton
onClick={() => {
if (!garminConfig?.connected && !gcForm.password) {
setGcError('Password is required for first-time setup')
return
}
const payload = { ...gcForm }
if (!payload.password) delete payload.password
saveGarmin.mutate(payload)
}}
loading={saveGarmin.isPending}
saved={gcSaved}
label={garminConfig?.connected ? 'Update' : 'Connect'}
/>
{garminConfig?.connected && (
<>
<button
onClick={triggerGarminSync}
disabled={gcSyncing}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{gcSyncing ? 'Syncing…' : '↻ Sync now'}
</button>
<button
onClick={() => { if (confirm('Remove Garmin Connect credentials?')) deleteGarmin.mutate() }}
className="text-red-400 hover:text-red-300 text-sm transition-colors">
Disconnect
</button>
</>
)}
</div>
</Section>
{/* PocketID — admin only */}
{user?.is_admin && (
<Section title="🔑 PocketID Passkey Authentication (Admin)">