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 (
{description}
Drop files here…
) : (Drag & drop files here, or click to browse
{Object.values(accept).flat().join(', ')}
Uploading…
)} {tasks.length > 0 && (Import activities from Garmin or Strava. Large exports are processed in the background.
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`}