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
Build and push images / validate (push) Successful in 3s
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 8s

This commit is contained in:
2026-06-21 10:18:09 +01:00
parent e8615dd12d
commit 07139120df
12 changed files with 1169 additions and 116 deletions
+147
View File
@@ -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 arent 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 sites 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)">
+4 -3
View File
@@ -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'],
}}
/>