feat: Strava support — bulk-export .gz/.tcx import fix + .tcx parser; full Strava API OAuth live sync (activities via streams) with Profile connect UI; colour gym/no-GPS sports red
This commit is contained in:
@@ -175,6 +175,65 @@ export default function ProfilePage() {
|
||||
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
|
||||
},
|
||||
})
|
||||
// Strava sync
|
||||
const { data: stravaConfig, refetch: refetchStrava } = useQuery({
|
||||
queryKey: ['strava-config'],
|
||||
queryFn: () => api.get('/strava-sync/config').then(r => r.data),
|
||||
// Poll while a sync is running so the status text stays live.
|
||||
refetchInterval: q => {
|
||||
const s = q.state.data?.last_sync_status || ''
|
||||
const running = s && !/^(OK|Error|Auth error|Connected|Cancelled|Lookback)/.test(s)
|
||||
return running ? 3000 : false
|
||||
},
|
||||
})
|
||||
const [stForm, setStForm] = useState({ sync_enabled: true, sync_lookback_days: '30' })
|
||||
const [stSaved, setStSaved] = useState(false)
|
||||
const stFormLoaded = useRef(false)
|
||||
useEffect(() => {
|
||||
if (stravaConfig?.connected && !stFormLoaded.current) {
|
||||
stFormLoaded.current = true
|
||||
setStForm({
|
||||
sync_enabled: stravaConfig.sync_enabled,
|
||||
sync_lookback_days: String(stravaConfig.sync_lookback_days ?? 30),
|
||||
})
|
||||
} else if (!stravaConfig?.connected) {
|
||||
stFormLoaded.current = false
|
||||
}
|
||||
}, [stravaConfig])
|
||||
// OAuth return banner (?strava=connected|error|scope), then strip the query.
|
||||
const [stReturn, setStReturn] = useState('')
|
||||
useEffect(() => {
|
||||
const p = new URLSearchParams(window.location.search)
|
||||
const s = p.get('strava')
|
||||
if (s) {
|
||||
setStReturn(s)
|
||||
refetchStrava()
|
||||
p.delete('strava')
|
||||
const qs = p.toString()
|
||||
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
||||
}
|
||||
}, [])
|
||||
const connectStrava = useMutation({
|
||||
mutationFn: () => api.get('/strava-sync/authorize').then(r => r.data),
|
||||
onSuccess: d => { if (d?.url) window.location.href = d.url },
|
||||
})
|
||||
const saveStrava = useMutation({
|
||||
mutationFn: data => api.put('/strava-sync/config', data).then(r => r.data),
|
||||
onSuccess: () => { refetchStrava(); setStSaved(true); setTimeout(() => setStSaved(false), 3000) },
|
||||
})
|
||||
const triggerStrava = useMutation({
|
||||
mutationFn: () => api.post('/strava-sync/trigger'),
|
||||
onSuccess: () => setTimeout(refetchStrava, 1000),
|
||||
})
|
||||
const deleteStrava = useMutation({
|
||||
mutationFn: () => api.delete('/strava-sync/config'),
|
||||
onSuccess: () => { refetchStrava(); setStForm({ sync_enabled: true, sync_lookback_days: '30' }) },
|
||||
})
|
||||
const stravaSyncing = (() => {
|
||||
const s = stravaConfig?.last_sync_status || ''
|
||||
return !!s && !/^(OK|Error|Auth error|Connected|Cancelled|Lookback)/.test(s)
|
||||
})()
|
||||
|
||||
// PocketID config
|
||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
|
||||
const [pidSaved, setPidSaved] = useState(false)
|
||||
@@ -445,6 +504,94 @@ export default function ProfilePage() {
|
||||
})()}
|
||||
</Section>
|
||||
|
||||
{/* Strava Sync */}
|
||||
<Section title="🟠 Strava Sync">
|
||||
<p className="text-xs text-gray-500">
|
||||
Connect your Strava account to automatically import activities {formatSyncInterval(stravaConfig?.sync_interval_minutes)}.
|
||||
Works for anything that ends up on Strava — Apple Watch, the Strava phone app, or another GPS watch.
|
||||
</p>
|
||||
|
||||
{stReturn === 'connected' && (
|
||||
<p className="text-xs text-green-400">✓ Strava connected — your first sync has started.</p>
|
||||
)}
|
||||
{stReturn === 'error' && (
|
||||
<p className="text-xs text-red-400">Strava connection failed or was cancelled. Please try again.</p>
|
||||
)}
|
||||
{stReturn === 'scope' && (
|
||||
<p className="text-xs text-yellow-400">Please tick “View data about your activities” when authorizing, so private activities can sync.</p>
|
||||
)}
|
||||
|
||||
{!stravaConfig?.configured && (
|
||||
<p className="text-xs text-yellow-400">
|
||||
Strava API credentials aren’t set on the server. An admin must register an app at
|
||||
strava.com/settings/api and set <code className="text-gray-300">STRAVA_CLIENT_ID</code> /
|
||||
<code className="text-gray-300"> STRAVA_CLIENT_SECRET</code> (callback domain = this site’s domain).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{stravaConfig?.connected ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between bg-orange-900/20 border border-orange-800/40 rounded-lg px-3 py-2 text-xs flex-wrap gap-2">
|
||||
<span className="text-orange-300">✓ Connected{stravaConfig.athlete_name ? ` as ${stravaConfig.athlete_name}` : ''}</span>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{stravaConfig.last_sync_at && (
|
||||
<span className="text-gray-500">
|
||||
Last sync: {new Date(stravaConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
{stravaConfig.last_sync_status && (
|
||||
<span className={stravaConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : /error/i.test(stravaConfig.last_sync_status) ? 'text-red-400' : 'text-yellow-400'}>
|
||||
{stravaConfig.last_sync_status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer pt-1">
|
||||
<input type="checkbox" checked={stForm.sync_enabled}
|
||||
onChange={e => setStForm(f => ({ ...f, sync_enabled: e.target.checked }))}
|
||||
className="w-4 h-4 accent-orange-500" />
|
||||
<span className="text-sm text-gray-300">Enable automatic sync ({formatSyncInterval(stravaConfig?.sync_interval_minutes)})</span>
|
||||
</label>
|
||||
|
||||
<Field label="Sync lookback days" hint="How far back to pull on the first sync (-1 = all history). After that, scheduled syncs only refresh the last day or two. Large backfills may hit Strava's rate limits and resume on the next sync.">
|
||||
<Input type="number" value={stForm.sync_lookback_days} min={-1}
|
||||
onChange={e => setStForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap pt-1">
|
||||
<SaveButton
|
||||
onClick={() => saveStrava.mutate({
|
||||
sync_enabled: stForm.sync_enabled,
|
||||
sync_lookback_days: parseInt(stForm.sync_lookback_days, 10) || 30,
|
||||
})}
|
||||
loading={saveStrava.isPending}
|
||||
saved={stSaved}
|
||||
label="Update"
|
||||
/>
|
||||
<button
|
||||
onClick={() => triggerStrava.mutate()}
|
||||
disabled={stravaSyncing || triggerStrava.isPending}
|
||||
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">
|
||||
{stravaSyncing ? 'Syncing…' : '↻ Sync now'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Disconnect Strava?')) deleteStrava.mutate() }}
|
||||
className="text-red-400 hover:text-red-300 text-sm transition-colors">
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => connectStrava.mutate()}
|
||||
disabled={!stravaConfig?.configured || connectStrava.isPending}
|
||||
className="inline-flex items-center gap-2 bg-[#FC4C02] hover:bg-[#e34402] disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
{connectStrava.isPending ? 'Redirecting…' : 'Connect with Strava'}
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* PocketID — admin only */}
|
||||
{user?.is_admin && (
|
||||
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
||||
|
||||
@@ -169,20 +169,21 @@ export default function UploadPage() {
|
||||
<li>Click "Request Your Archive"</li>
|
||||
<li>Download and upload the ZIP file below</li>
|
||||
</ol>
|
||||
<p className="text-gray-500 text-xs mt-2">Handles the gzipped <code>.fit.gz</code>/<code>.gpx.gz</code>/<code>.tcx.gz</code> files Strava puts in the archive. For ongoing sync, connect Strava on the Profile page instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* Single FIT/GPX */}
|
||||
{/* Single FIT/GPX/TCX */}
|
||||
<UploadZone
|
||||
title="Single activity"
|
||||
description="Upload a .fit or .gpx file"
|
||||
description="Upload a .fit, .gpx or .tcx file"
|
||||
icon="🏃"
|
||||
endpoint="/upload/activity"
|
||||
accept={{
|
||||
'application/octet-stream': ['.fit'],
|
||||
'application/gpx+xml': ['.gpx'],
|
||||
'text/xml': ['.gpx'],
|
||||
'text/xml': ['.gpx', '.tcx'],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user