Add Garmin Connect auto-sync via python-garminconnect
- 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:
@@ -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)">
|
||||
|
||||
Reference in New Issue
Block a user