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 (
{description}
Drop files here…
) : (Drag & drop files here, or click to browse
{Object.values(accept).flat().join(', ')}
Uploading…
)} {tasks.length > 0 && ({task.reason}
)}Preview duplicates before importing — Garmin data is kept on a match
Uploading…
:Drag & drop your Strava archive .zip, or click to browse
We’ll scan it and show what’s new before importing
Scanning archive for duplicates…
)} {phase === 'report' && report && (New e.g.: {report.new_samples.slice(0, 4).join(' · ')} {report.new > 4 ? ' …' : ''}
)}Duplicates are skipped automatically — your existing Garmin activities are kept.
Queuing import…
)} {phase === 'done' && (✓ Import queued — new activities will appear shortly as they process.
{error || 'Something went wrong'}
Import activities from Garmin or Strava. Large exports are processed in the background.
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.
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`}