Files
MileVault/frontend/src/pages/UploadPage.jsx
T
owain 69ecdaa4b2
Build and push images / validate (push) Successful in 2s
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 9s
feat: Strava export dry-run preview (new vs duplicate counts + confirm import) and prefer existing Garmin data over Strava on dedup
2026-06-21 10:41:54 +01:00

392 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <span className="ml-2 text-blue-400 animate-pulse"> Processing</span>
if (status === 'done') return <span className="ml-2 text-green-400"> Done</span>
if (status === 'skipped') return <span className="ml-2 text-amber-400"> Skipped</span>
if (status === 'failed') return <span className="ml-2 text-red-400"> Failed</span>
return <span className="ml-2 text-green-400"> Queued</span>
}
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">{icon}</span>
<div>
<h3 className="font-semibold text-white">{title}</h3>
<p className="text-xs text-gray-500">{description}</p>
</div>
</div>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-blue-500 bg-blue-950/30'
: 'border-gray-700 hover:border-gray-500 hover:bg-gray-800/30'
}`}
>
<input {...getInputProps()} />
{isDragActive ? (
<p className="text-blue-400 text-sm">Drop files here</p>
) : (
<div>
<p className="text-gray-400 text-sm">Drag & drop files here, or click to browse</p>
<p className="text-gray-600 text-xs mt-1">
{Object.values(accept).flat().join(', ')}
</p>
</div>
)}
</div>
{upload.isPending && (
<p className="text-xs text-blue-400 mt-2 animate-pulse">Uploading</p>
)}
{tasks.length > 0 && (
<div className="mt-4 space-y-2">
{tasks.map((task, i) => (
<div key={i} className="bg-gray-800 rounded-lg px-3 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-300 truncate flex-1">{task.file}</span>
{task.activity_tasks !== undefined && (
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
)}
<StatusBadge status={task.status} />
</div>
{task.reason && (task.status === 'skipped' || task.status === 'failed') && (
<p className={`text-xs mt-1 ${task.status === 'skipped' ? 'text-amber-400/80' : 'text-red-400/80'}`}>
{task.reason}
</p>
)}
</div>
))}
</div>
)}
</div>
)
}
// 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 (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">🚴</span>
<div>
<h3 className="font-semibold text-white">Strava bulk export</h3>
<p className="text-xs text-gray-500">Preview duplicates before importing Garmin data is kept on a match</p>
</div>
</div>
{(phase === 'idle' || phase === 'uploading') && (
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragActive ? 'border-blue-500 bg-blue-950/30' : 'border-gray-700 hover:border-gray-500 hover:bg-gray-800/30'
}`}
>
<input {...getInputProps()} />
{phase === 'uploading'
? <p className="text-blue-400 text-sm animate-pulse">Uploading</p>
: <div>
<p className="text-gray-400 text-sm">Drag & drop your Strava archive .zip, or click to browse</p>
<p className="text-gray-600 text-xs mt-1">Well scan it and show whats new before importing</p>
</div>}
</div>
)}
{phase === 'analyzing' && (
<p className="text-sm text-blue-400 animate-pulse py-4 text-center">Scanning archive for duplicates</p>
)}
{phase === 'report' && report && (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
<div className="bg-green-950/30 border border-green-900/40 rounded-lg py-2">
<div className="text-xl font-bold text-green-400">{report.new}</div>
<div className="text-xs text-gray-500">new</div>
</div>
<div className="bg-gray-800 border border-gray-700 rounded-lg py-2">
<div className="text-xl font-bold text-gray-300">{report.duplicate}</div>
<div className="text-xs text-gray-500">already have</div>
</div>
<div className="bg-amber-950/20 border border-amber-900/40 rounded-lg py-2">
<div className="text-xl font-bold text-amber-400">{report.unreadable}</div>
<div className="text-xs text-gray-500">unreadable</div>
</div>
</div>
{report.new_samples?.length > 0 && (
<p className="text-xs text-gray-500">
New e.g.: <span className="text-gray-400">{report.new_samples.slice(0, 4).join(' · ')}</span>
{report.new > 4 ? ' …' : ''}
</p>
)}
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={doImport}
disabled={report.new === 0}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{report.new === 0 ? 'Nothing new to import' : `Import ${report.new} new ${report.new === 1 ? 'activity' : 'activities'}`}
</button>
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
Choose another file
</button>
</div>
<p className="text-xs text-gray-600">Duplicates are skipped automatically your existing Garmin activities are kept.</p>
</div>
)}
{phase === 'importing' && (
<p className="text-sm text-blue-400 animate-pulse py-4 text-center">Queuing import</p>
)}
{phase === 'done' && (
<div className="py-3 space-y-2">
<p className="text-sm text-green-400"> Import queued new activities will appear shortly as they process.</p>
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
Import another export
</button>
</div>
)}
{phase === 'error' && (
<div className="py-3 space-y-2">
<p className="text-sm text-red-400">{error || 'Something went wrong'}</p>
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
Try again
</button>
</div>
)}
</div>
)
}
export default function UploadPage() {
return (
<div className="p-4 md:p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Import Data</h1>
<p className="text-gray-500 text-sm mt-1">
Import activities from Garmin or Strava. Large exports are processed in the background.
</p>
</div>
{/* How to export guides */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-blue-950/30 border border-blue-900/50 rounded-xl p-4 text-sm">
<h3 className="font-semibold text-blue-300 mb-2">📥 How to export from Garmin Connect</h3>
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
<li>Go to Garmin Connect Profile Account</li>
<li>Scroll to Data Management Export Your Data</li>
<li>Request export and wait for the email</li>
<li>Download and upload the ZIP file below</li>
</ol>
</div>
<div className="bg-orange-950/20 border border-orange-900/40 rounded-xl p-4 text-sm">
<h3 className="font-semibold text-orange-300 mb-2">📥 How to export from Strava</h3>
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
<li>Go to strava.com Settings My Account</li>
<li>Scroll to Download or Delete Your Account</li>
<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/TCX */}
<UploadZone
title="Single activity"
description="Upload a .fit, .gpx or .tcx file"
icon="🏃"
endpoint="/upload/activity"
accept={{
'application/octet-stream': ['.fit'],
'application/gpx+xml': ['.gpx'],
'text/xml': ['.gpx', '.tcx'],
}}
/>
{/* Garmin full export */}
<UploadZone
title="Garmin Connect export"
description="Upload your full Garmin data export ZIP"
icon="⌚"
endpoint="/upload/garmin-export"
accept={{ 'application/zip': ['.zip'] }}
/>
{/* Strava export — preview duplicates, then import */}
<StravaExportZone />
{/* Ongoing FIT files */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">🔄</span>
<div>
<h3 className="font-semibold text-white">Ongoing sync</h3>
<p className="text-xs text-gray-500">Automatically import new Garmin watch files</p>
</div>
</div>
<div className="space-y-3 text-xs text-gray-500">
<p>After each activity, sync your Garmin watch via USB or Garmin Express. New FIT files appear in:</p>
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono">
GARMIN/Activity/*.fit
</code>
<p>Upload individual FIT files above using the "Single activity" uploader, or set up a folder-watch script:</p>
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono whitespace-pre">
{`# 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`}
</code>
</div>
</div>
</div>
</div>
)
}