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:
+69
-19
@@ -3,12 +3,13 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.workers.tasks import process_activity_file, process_garmin_health_zip
|
from app.workers.tasks import process_activity_file, process_garmin_health_zip, analyze_strava_export
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -187,13 +188,39 @@ async def upload_garmin_export(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_activity_files(extracted: list[Path]) -> list[Path]:
|
||||||
|
"""From extracted archive members, gunzip any .gz wrappers (Strava ships most
|
||||||
|
activities as <id>.fit.gz / .gpx.gz / .tcx.gz) and return the .fit/.gpx/.tcx
|
||||||
|
paths ready to parse. Without this, the gzipped majority is silently skipped."""
|
||||||
|
files: list[Path] = []
|
||||||
|
for path in extracted:
|
||||||
|
if path.suffix.lower() == ".gz":
|
||||||
|
inner = _gunzip(path)
|
||||||
|
if inner is None:
|
||||||
|
continue
|
||||||
|
path = inner
|
||||||
|
if path.suffix.lower() in (".fit", ".gpx", ".tcx"):
|
||||||
|
files.append(path)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
class StravaConfirmIn(BaseModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/strava-export")
|
@router.post("/strava-export")
|
||||||
async def upload_strava_export(
|
async def upload_strava_export(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
dry_run: bool = False,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files)."""
|
"""Upload a Strava bulk export ZIP (activities/ folder with GPX/FIT/TCX files).
|
||||||
|
|
||||||
|
With `dry_run=true` nothing is imported: the archive is extracted and a single
|
||||||
|
analysis task reports how many activities are new vs. already present (Garmin
|
||||||
|
data is preferred), returning a `token` to confirm the real import with.
|
||||||
|
Otherwise activities import straight away (existing data is kept on a match)."""
|
||||||
if not file.filename.endswith(".zip"):
|
if not file.filename.endswith(".zip"):
|
||||||
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
|
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
|
||||||
|
|
||||||
@@ -202,7 +229,6 @@ async def upload_strava_export(
|
|||||||
|
|
||||||
extract_dir = dest_dir / f"strava_{dest.stem}"
|
extract_dir = dest_dir / f"strava_{dest.stem}"
|
||||||
|
|
||||||
task_ids = []
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(dest) as zf:
|
with zipfile.ZipFile(dest) as zf:
|
||||||
extracted = _safe_extract(zf, extract_dir)
|
extracted = _safe_extract(zf, extract_dir)
|
||||||
@@ -210,27 +236,25 @@ async def upload_strava_export(
|
|||||||
dest.unlink(missing_ok=True)
|
dest.unlink(missing_ok=True)
|
||||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||||
|
|
||||||
for path in extracted:
|
files = _collect_activity_files(extracted)
|
||||||
# Strava compresses most exported activities as <id>.fit.gz / .gpx.gz /
|
if not files:
|
||||||
# .tcx.gz. Decompress those to their underlying file first, otherwise the
|
|
||||||
# gzipped majority of the archive is silently skipped.
|
|
||||||
if path.suffix.lower() == ".gz":
|
|
||||||
inner = _gunzip(path)
|
|
||||||
if inner is None:
|
|
||||||
continue
|
|
||||||
path = inner
|
|
||||||
|
|
||||||
suffix = path.suffix.lower()
|
|
||||||
if suffix in (".fit", ".gpx", ".tcx"):
|
|
||||||
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
|
|
||||||
task_ids.append(task.id)
|
|
||||||
|
|
||||||
if not task_ids:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="No activity files (.fit, .gpx or .tcx) found in this Strava archive",
|
detail="No activity files (.fit, .gpx or .tcx) found in this Strava archive",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
# Preview only — classify new/duplicate without writing. The extracted
|
||||||
|
# files are kept so the confirm step can import them without re-uploading.
|
||||||
|
task = analyze_strava_export.delay([str(p) for p in files], current_user.id)
|
||||||
|
return {"status": "analyzing", "task_id": task.id, "token": dest.stem,
|
||||||
|
"activity_files": len(files)}
|
||||||
|
|
||||||
|
task_ids = [
|
||||||
|
process_activity_file.delay(str(p), current_user.id, p.suffix.lower()[1:],
|
||||||
|
prefer_existing=True).id
|
||||||
|
for p in files
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"activity_tasks": len(task_ids),
|
"activity_tasks": len(task_ids),
|
||||||
@@ -238,6 +262,32 @@ async def upload_strava_export(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/strava-export/confirm")
|
||||||
|
async def confirm_strava_export(
|
||||||
|
body: StravaConfirmIn,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Import the activities from a previously previewed (dry-run) Strava export.
|
||||||
|
`token` is the value returned by the dry-run upload; the already-extracted
|
||||||
|
files are imported with Garmin data preferred over Strava duplicates."""
|
||||||
|
token = _safe_name(body.token)
|
||||||
|
extract_dir = Path(settings.file_store_path) / str(current_user.id) / "exports" / f"strava_{token}"
|
||||||
|
if not extract_dir.is_dir():
|
||||||
|
raise HTTPException(status_code=404, detail="Preview not found — please upload and preview again")
|
||||||
|
|
||||||
|
files = [p for p in extract_dir.rglob("*")
|
||||||
|
if p.is_file() and p.suffix.lower() in (".fit", ".gpx", ".tcx")]
|
||||||
|
if not files:
|
||||||
|
raise HTTPException(status_code=400, detail="No activity files found for this preview")
|
||||||
|
|
||||||
|
task_ids = [
|
||||||
|
process_activity_file.delay(str(p), current_user.id, p.suffix.lower()[1:],
|
||||||
|
prefer_existing=True).id
|
||||||
|
for p in files
|
||||||
|
]
|
||||||
|
return {"status": "queued", "activity_tasks": len(task_ids)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/task/{task_id}")
|
@router.get("/task/{task_id}")
|
||||||
async def check_task_status(
|
async def check_task_status(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|||||||
@@ -90,11 +90,14 @@ def _apply_garmin_summary(parsed: dict, summary: dict):
|
|||||||
|
|
||||||
@celery_app.task(bind=True, name="process_activity_file")
|
@celery_app.task(bind=True, name="process_activity_file")
|
||||||
def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||||
garmin_activity_id: str = None, summary: dict = None):
|
garmin_activity_id: str = None, summary: dict = None,
|
||||||
|
prefer_existing: bool = False):
|
||||||
"""Parse a FIT/GPX file. Routes wellness files to health parser.
|
"""Parse a FIT/GPX file. Routes wellness files to health parser.
|
||||||
|
|
||||||
`summary` (optional, from Garmin Connect sync) carries Garmin's corrected
|
`summary` (optional, from Garmin Connect sync) carries Garmin's corrected
|
||||||
distance/moving/elapsed values which override the raw FIT figures."""
|
distance/moving/elapsed values which override the raw FIT figures.
|
||||||
|
`prefer_existing` (Strava-export imports) keeps an existing same-time
|
||||||
|
activity instead of adding a duplicate — Garmin data wins."""
|
||||||
|
|
||||||
if is_wellness_file(file_path):
|
if is_wellness_file(file_path):
|
||||||
parse_wellness_fit.delay(file_path, user_id)
|
parse_wellness_fit.delay(file_path, user_id)
|
||||||
@@ -134,20 +137,46 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
|||||||
db, user_id, parsed,
|
db, user_id, parsed,
|
||||||
source_file=file_path,
|
source_file=file_path,
|
||||||
garmin_activity_id=garmin_activity_id,
|
garmin_activity_id=garmin_activity_id,
|
||||||
|
prefer_existing=prefer_existing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_existing_activity(db, user_id: int, sport_type: str, start_time,
|
||||||
|
prefer_existing: bool = False):
|
||||||
|
"""Find an already-imported activity that matches `start_time` (±60s) for this
|
||||||
|
user. Normally also requires the same sport_type. When `prefer_existing` is
|
||||||
|
set (Strava-export imports), the sport_type filter is dropped so a Strava
|
||||||
|
file is treated as a duplicate of an existing activity — typically the Garmin
|
||||||
|
original — even when the two sources labelled the sport differently. This is
|
||||||
|
what makes Garmin data win over Strava re-imports."""
|
||||||
|
from app.models.user import Activity
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
conds = [
|
||||||
|
Activity.user_id == user_id,
|
||||||
|
Activity.start_time >= start_time - timedelta(seconds=60),
|
||||||
|
Activity.start_time <= start_time + timedelta(seconds=60),
|
||||||
|
]
|
||||||
|
if not prefer_existing:
|
||||||
|
conds.append(Activity.sport_type == sport_type)
|
||||||
|
return db.execute(select(Activity).where(*conds)).scalars().first()
|
||||||
|
|
||||||
|
|
||||||
def persist_activity(db, user_id: int, parsed: dict, *, source_file: str = None,
|
def persist_activity(db, user_id: int, parsed: dict, *, source_file: str = None,
|
||||||
garmin_activity_id: str = None, strava_activity_id: str = None) -> dict:
|
garmin_activity_id: str = None, strava_activity_id: str = None,
|
||||||
|
prefer_existing: bool = False) -> dict:
|
||||||
"""Insert a parsed activity (+ data points, laps) and dispatch the
|
"""Insert a parsed activity (+ data points, laps) and dispatch the
|
||||||
PR/route/segment follow-up tasks. Shared by the file-upload path
|
PR/route/segment follow-up tasks. Shared by the file-upload path
|
||||||
(process_activity_file) and the Strava API sync, so both get identical
|
(process_activity_file) and the Strava API sync, so both get identical
|
||||||
dedup, HR-zone and downstream behaviour. `parsed` is the fit_parser-shaped
|
dedup, HR-zone and downstream behaviour. `parsed` is the fit_parser-shaped
|
||||||
dict. Returns {"activity_id", "status"}."""
|
dict. `prefer_existing` skips this activity if any existing one matches by
|
||||||
|
time alone (used by Strava imports to keep the Garmin original).
|
||||||
|
Returns {"activity_id", "status"}."""
|
||||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap, User as UserModel
|
from app.models.user import Activity, ActivityDataPoint, ActivityLap, User as UserModel
|
||||||
from app.services.fit_parser import calculate_hr_zones
|
from app.services.fit_parser import calculate_hr_zones
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
|
|
||||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||||
|
|
||||||
@@ -159,16 +188,11 @@ def persist_activity(db, user_id: int, parsed: dict, *, source_file: str = None,
|
|||||||
if existing:
|
if existing:
|
||||||
return {"activity_id": existing.id, "status": "duplicate"}
|
return {"activity_id": existing.id, "status": "duplicate"}
|
||||||
|
|
||||||
# Deduplicate across sources: same user + sport_type + start_time within ±60s.
|
# Deduplicate across sources (see _find_existing_activity). This collapses an
|
||||||
# This also collapses an activity synced from both Garmin and Strava into one.
|
# activity present from both Garmin and Strava into the one already stored.
|
||||||
existing = db.execute(
|
existing = _find_existing_activity(
|
||||||
select(Activity).where(
|
db, user_id, parsed["sport_type"], start_time, prefer_existing=prefer_existing,
|
||||||
Activity.user_id == user_id,
|
|
||||||
Activity.sport_type == parsed["sport_type"],
|
|
||||||
Activity.start_time >= start_time - timedelta(seconds=60),
|
|
||||||
Activity.start_time <= start_time + timedelta(seconds=60),
|
|
||||||
)
|
)
|
||||||
).scalars().first()
|
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Stamp the external id so future syncs skip straight to the fast path.
|
# Stamp the external id so future syncs skip straight to the fast path.
|
||||||
@@ -287,6 +311,63 @@ def persist_activity(db, user_id: int, parsed: dict, *, source_file: str = None,
|
|||||||
return {"activity_id": activity_id, "status": "ok"}
|
return {"activity_id": activity_id, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="analyze_strava_export")
|
||||||
|
def analyze_strava_export(file_paths: list, user_id: int):
|
||||||
|
"""Dry-run for a Strava bulk export: parse each activity file and classify it
|
||||||
|
as new / duplicate / unreadable against existing activities, WITHOUT writing
|
||||||
|
anything. Uses the same Garmin-priority dedup the real import uses, so the
|
||||||
|
preview matches what the import would actually do."""
|
||||||
|
from app.services.fit_parser import parse_fit_file, parse_gpx_file, parse_tcx_file
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
new = duplicate = unreadable = 0
|
||||||
|
new_samples = []
|
||||||
|
dup_samples = []
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
for fp in file_paths:
|
||||||
|
try:
|
||||||
|
low = fp.lower()
|
||||||
|
if low.endswith(".fit"):
|
||||||
|
parsed = parse_fit_file(fp)
|
||||||
|
elif low.endswith(".tcx"):
|
||||||
|
parsed = parse_tcx_file(fp)
|
||||||
|
else:
|
||||||
|
parsed = parse_gpx_file(fp)
|
||||||
|
start_str = parsed.get("start_time")
|
||||||
|
if not start_str:
|
||||||
|
unreadable += 1
|
||||||
|
continue
|
||||||
|
start_time = datetime.fromisoformat(start_str)
|
||||||
|
except Exception:
|
||||||
|
unreadable += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = _find_existing_activity(
|
||||||
|
db, user_id, parsed.get("sport_type"), start_time, prefer_existing=True,
|
||||||
|
)
|
||||||
|
label = parsed.get("name") or start_str
|
||||||
|
if existing:
|
||||||
|
duplicate += 1
|
||||||
|
if len(dup_samples) < 8:
|
||||||
|
dup_samples.append(label)
|
||||||
|
else:
|
||||||
|
new += 1
|
||||||
|
if len(new_samples) < 8:
|
||||||
|
new_samples.append(label)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"total": new + duplicate + unreadable,
|
||||||
|
"new": new,
|
||||||
|
"duplicate": duplicate,
|
||||||
|
"unreadable": unreadable,
|
||||||
|
"new_samples": new_samples,
|
||||||
|
"duplicate_samples": dup_samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="parse_wellness_fit")
|
@celery_app.task(name="parse_wellness_fit")
|
||||||
def parse_wellness_fit(file_path: str, user_id: int):
|
def parse_wellness_fit(file_path: str, user_id: int):
|
||||||
"""Parse a Garmin wellness FIT file and upsert into health_metrics."""
|
"""Parse a Garmin wellness FIT file and upsert into health_metrics."""
|
||||||
|
|||||||
@@ -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() {
|
export default function UploadPage() {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 space-y-6">
|
<div className="p-4 md:p-6 space-y-6">
|
||||||
@@ -196,14 +356,8 @@ export default function UploadPage() {
|
|||||||
accept={{ 'application/zip': ['.zip'] }}
|
accept={{ 'application/zip': ['.zip'] }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Strava export */}
|
{/* Strava export — preview duplicates, then import */}
|
||||||
<UploadZone
|
<StravaExportZone />
|
||||||
title="Strava bulk export"
|
|
||||||
description="Upload your Strava archive ZIP"
|
|
||||||
icon="🚴"
|
|
||||||
endpoint="/upload/strava-export"
|
|
||||||
accept={{ 'application/zip': ['.zip'] }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Ongoing FIT files */}
|
{/* Ongoing FIT files */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
|
|||||||
Reference in New Issue
Block a user