import { useState, useCallback, useEffect, useRef } from 'react' import { useDropzone } from 'react-dropzone' import { useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' function UploadZone({ title, description, accept, endpoint, icon }) { const [tasks, setTasks] = useState([]) const queryClient = useQueryClient() const intervalsRef = useRef({}) const pollTask = useCallback((taskId) => { if (intervalsRef.current[taskId]) return const intervalId = setInterval(async () => { try { const { data } = await api.get(`/upload/task/${taskId}`) if (data.status === 'SUCCESS' || data.status === 'FAILURE') { clearInterval(intervalsRef.current[taskId]) delete intervalsRef.current[taskId] // A successful task may still have skipped the file (e.g. a duplicate or // an activity that looks like vehicle travel) — surface the reason. const skipped = data.status === 'SUCCESS' && data.result?.status === 'skipped' setTasks(ts => ts.map(t => t.task_id === taskId ? { ...t, status: data.status === 'FAILURE' ? 'failed' : skipped ? 'skipped' : 'done', reason: skipped ? data.result?.reason : t.reason } : t )) if (data.status === 'SUCCESS' && !skipped) { queryClient.invalidateQueries({ queryKey: ['activities'] }) queryClient.invalidateQueries({ queryKey: ['health-summary'] }) queryClient.invalidateQueries({ queryKey: ['health-metrics'] }) } } } catch { /* ignore transient poll errors */ } }, 2000) intervalsRef.current[taskId] = intervalId }, [queryClient]) useEffect(() => { return () => { Object.values(intervalsRef.current).forEach(clearInterval) } }, []) const upload = useMutation({ mutationFn: async (file) => { const form = new FormData() form.append('file', file) const { data } = await api.post(endpoint, form, { headers: { 'Content-Type': 'multipart/form-data' }, }) return { file: file.name, ...data } }, onSuccess: (data) => { const task = { ...data, status: data.task_id ? 'processing' : 'queued' } setTasks(t => [...t, task]) if (data.task_id) { pollTask(data.task_id) } }, onError: (err, file) => { const reason = err.response?.data?.detail || 'Upload failed' setTasks(t => [...t, { file: file?.name || String(file), status: 'failed', reason }]) }, }) const onDrop = useCallback((accepted) => { accepted.forEach(file => upload.mutate(file)) }, [upload]) const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept, multiple: true, }) function StatusBadge({ status }) { if (status === 'processing') return ⏳ Processing if (status === 'done') return ✓ Done if (status === 'skipped') return ⚠ Skipped if (status === 'failed') return ✗ Failed return ✓ Queued } return (
{icon}

{title}

{description}

{isDragActive ? (

Drop files here…

) : (

Drag & drop files here, or click to browse

{Object.values(accept).flat().join(', ')}

)}
{upload.isPending && (

Uploading…

)} {tasks.length > 0 && (
{tasks.map((task, i) => (
{task.file} {task.activity_tasks !== undefined && ( {task.activity_tasks} activities queued )}
{task.reason && (task.status === 'skipped' || task.status === 'failed') && (

{task.reason}

)}
))}
)}
) } // Strava bulk export with a dry-run preview: upload → analyse (new vs. already // present, Garmin preferred) → confirm import of just the new ones. Avoids // re-uploading by importing the files the preview already extracted (via token). function StravaExportZone() { const qc = useQueryClient() const [phase, setPhase] = useState('idle') // idle|uploading|analyzing|report|importing|done|error const [report, setReport] = useState(null) const [token, setToken] = useState(null) const [error, setError] = useState('') const pollRef = useRef(null) const reset = () => { setReport(null); setToken(null); setError('') } useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, []) const pollAnalyze = useCallback((taskId) => { pollRef.current = setInterval(async () => { try { const { data } = await api.get(`/upload/task/${taskId}`) if (data.status === 'SUCCESS') { clearInterval(pollRef.current) setReport(data.result) setPhase('report') } else if (data.status === 'FAILURE') { clearInterval(pollRef.current) setError('Analysis failed — please try again') setPhase('error') } } catch { /* ignore transient poll errors */ } }, 2000) }, []) const onDrop = useCallback(async (accepted) => { const file = accepted[0] if (!file) return reset() setPhase('uploading') try { const form = new FormData() form.append('file', file) const { data } = await api.post('/upload/strava-export?dry_run=true', form, { headers: { 'Content-Type': 'multipart/form-data' }, }) setToken(data.token) setPhase('analyzing') pollAnalyze(data.task_id) } catch (e) { setError(e.response?.data?.detail || 'Upload failed') setPhase('error') } }, [pollAnalyze]) const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: { 'application/zip': ['.zip'] }, multiple: false, }) const doImport = async () => { setPhase('importing') try { await api.post('/upload/strava-export/confirm', { token }) setPhase('done') qc.invalidateQueries({ queryKey: ['activities'] }) } catch (e) { setError(e.response?.data?.detail || 'Import failed') setPhase('error') } } return (
🚴

Strava bulk export

Preview duplicates before importing — Garmin data is kept on a match

{(phase === 'idle' || phase === 'uploading') && (
{phase === 'uploading' ?

Uploading…

:

Drag & drop your Strava archive .zip, or click to browse

We’ll scan it and show what’s new before importing

}
)} {phase === 'analyzing' && (

Scanning archive for duplicates…

)} {phase === 'report' && report && (
{report.new}
new
{report.duplicate}
already have
{report.unreadable}
unreadable
{report.new_samples?.length > 0 && (

New e.g.: {report.new_samples.slice(0, 4).join(' · ')} {report.new > 4 ? ' …' : ''}

)}

Duplicates are skipped automatically — your existing Garmin activities are kept.

)} {phase === 'importing' && (

Queuing import…

)} {phase === 'done' && (

✓ Import queued — new activities will appear shortly as they process.

)} {phase === 'error' && (

{error || 'Something went wrong'}

)}
) } export default function UploadPage() { return (

Import Data

Import activities from Garmin or Strava. Large exports are processed in the background.

{/* How to export guides */}

📥 How to export from Garmin Connect

  1. Go to Garmin Connect → Profile → Account
  2. Scroll to Data Management → Export Your Data
  3. Request export and wait for the email
  4. Download and upload the ZIP file below

📥 How to export from Strava

  1. Go to strava.com → Settings → My Account
  2. Scroll to Download or Delete Your Account
  3. Click "Request Your Archive"
  4. Download and upload the ZIP file below

Handles the gzipped .fit.gz/.gpx.gz/.tcx.gz files Strava puts in the archive. For ongoing sync, connect Strava on the Profile page instead.

{/* Single FIT/GPX/TCX */} {/* Garmin full export */} {/* Strava export — preview duplicates, then import */} {/* Ongoing FIT files */}
🔄

Ongoing sync

Automatically import new Garmin watch files

After each activity, sync your Garmin watch via USB or Garmin Express. New FIT files appear in:

GARMIN/Activity/*.fit

Upload individual FIT files above using the "Single activity" uploader, or set up a folder-watch script:

{`# Example: auto-upload new FIT files inotifywait -m ~/Garmin/Activity/ -e create \\ --format '%f' | while read file; do curl -X POST /api/upload/activity \\ -H "Authorization: Bearer TOKEN" \\ -F "file=@$file" done`}
) }