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] setTasks(ts => ts.map(t => t.task_id === taskId ? { ...t, status: data.status === 'SUCCESS' ? 'done' : 'failed' } : t )) if (data.status === 'SUCCESS') { 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) } }, }) 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 === '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 )}
))}
)}
) } 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
{/* Single FIT/GPX */} {/* Garmin full export */} {/* Strava export */} {/* 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`}
) }