feat: Strava export dry-run preview (new vs duplicate counts + confirm import) and prefer existing Garmin data over Strava on dedup
This commit is contained in:
@@ -140,6 +140,166 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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">We’ll scan it and show what’s 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">
|
||||
@@ -196,14 +356,8 @@ export default function UploadPage() {
|
||||
accept={{ 'application/zip': ['.zip'] }}
|
||||
/>
|
||||
|
||||
{/* Strava export */}
|
||||
<UploadZone
|
||||
title="Strava bulk export"
|
||||
description="Upload your Strava archive ZIP"
|
||||
icon="🚴"
|
||||
endpoint="/upload/strava-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">
|
||||
|
||||
Reference in New Issue
Block a user