Add trend-range gating, vehicle filter, sync cancel, moving time, and UI fixes
- Grey out trend ranges beyond available health history - Reject implausibly fast (vehicle) activities on upload with feedback - Add cancel button + cooperative cancellation for Garmin sync - Show daily steps prominently on the dashboard - Clear errors for malformed/empty upload ZIPs - Snap-target dot when drawing a segment on the map - Time-axis fallback for stationary/HIIT HR timelines; hide map when no GPS - Parse and display moving time (timer) vs elapsed; backfill task - Restyle SegmentsPanel like RouteLeaderboard; Laps/Routes/Segments on one row Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ class ActivitySummary(BaseModel):
|
||||
|
||||
class ActivityDetail(ActivitySummary):
|
||||
end_time: Optional[datetime]
|
||||
moving_time_s: Optional[float]
|
||||
elevation_loss_m: Optional[float]
|
||||
max_heart_rate: Optional[float]
|
||||
avg_power: Optional[float]
|
||||
|
||||
@@ -13,6 +13,19 @@ from app.models.user import User, GarminConnectConfig
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _redis_client():
|
||||
import redis as redis_lib
|
||||
return redis_lib.Redis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
def sync_task_key(user_id: int) -> str:
|
||||
return f"garmin_sync_task:{user_id}"
|
||||
|
||||
|
||||
def sync_cancel_key(user_id: int) -> str:
|
||||
return f"garmin_sync_cancel:{user_id}"
|
||||
|
||||
|
||||
class GarminConfigIn(BaseModel):
|
||||
email: str
|
||||
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing.
|
||||
@@ -183,4 +196,47 @@ async def trigger_sync(
|
||||
|
||||
from app.workers.tasks import sync_garmin_connect_user
|
||||
task = sync_garmin_connect_user.delay(current_user.id)
|
||||
|
||||
# Track the active task id and clear any stale cancel flag so the new sync runs.
|
||||
try:
|
||||
r = _redis_client()
|
||||
r.delete(sync_cancel_key(current_user.id))
|
||||
r.set(sync_task_key(current_user.id), task.id, ex=3600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
|
||||
|
||||
@router.post("/cancel")
|
||||
async def cancel_sync(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Request cancellation of the user's in-progress Garmin sync. The running task
|
||||
checks this flag between items and aborts cooperatively."""
|
||||
from app.workers.tasks import celery_app
|
||||
|
||||
try:
|
||||
r = _redis_client()
|
||||
r.set(sync_cancel_key(current_user.id), "1", ex=3600)
|
||||
task_id = r.get(sync_task_key(current_user.id))
|
||||
if task_id:
|
||||
tid = task_id.decode() if isinstance(task_id, (bytes, bytearray)) else task_id
|
||||
# terminate=False: don't kill a running worker mid-transaction; the
|
||||
# cooperative flag handles an already-running task, and this revoke
|
||||
# prevents a still-queued one from starting.
|
||||
celery_app.control.revoke(tid, terminate=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reflect intent immediately so the UI updates before the worker writes "Cancelled".
|
||||
result = await db.execute(
|
||||
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
|
||||
)
|
||||
cfg = result.scalar_one_or_none()
|
||||
if cfg:
|
||||
cfg.last_sync_status = "Cancelling…"
|
||||
await db.commit()
|
||||
|
||||
return {"status": "cancelling"}
|
||||
|
||||
@@ -115,14 +115,21 @@ async def upload_garmin_export(
|
||||
extract_dir = dest_dir / f"garmin_{dest.stem}"
|
||||
|
||||
task_ids = []
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
extracted = _safe_extract(zf, extract_dir)
|
||||
try:
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
extracted = _safe_extract(zf, extract_dir)
|
||||
except zipfile.BadZipFile:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||
|
||||
has_health = False
|
||||
for path in extracted:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".fit":
|
||||
task = process_activity_file.delay(str(path), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
elif suffix == ".json":
|
||||
has_health = True # Garmin wellness data is exported as JSON files
|
||||
elif suffix == ".zip":
|
||||
# Garmin exports nest activity FIT files inside sub-zips
|
||||
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
|
||||
@@ -137,6 +144,12 @@ async def upload_garmin_export(
|
||||
task = process_activity_file.delay(str(np), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
|
||||
if not task_ids and not has_health:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No fitness data found in this archive — make sure you uploaded your full Garmin Connect export ZIP",
|
||||
)
|
||||
|
||||
# Queue health/wellness data extraction
|
||||
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
||||
|
||||
@@ -163,8 +176,12 @@ async def upload_strava_export(
|
||||
extract_dir = dest_dir / f"strava_{dest.stem}"
|
||||
|
||||
task_ids = []
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
extracted = _safe_extract(zf, extract_dir)
|
||||
try:
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
extracted = _safe_extract(zf, extract_dir)
|
||||
except zipfile.BadZipFile:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||
|
||||
for path in extracted:
|
||||
suffix = path.suffix.lower()
|
||||
@@ -172,6 +189,12 @@ async def upload_strava_export(
|
||||
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
|
||||
task_ids.append(task.id)
|
||||
|
||||
if not task_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No activity files (.fit or .gpx) found in this Strava archive",
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
"activity_tasks": len(task_ids),
|
||||
|
||||
Reference in New Issue
Block a user