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:
+108
-20
@@ -80,6 +80,11 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
if not parsed.get("start_time"):
|
||||
return {"status": "skipped", "reason": "no start_time", "file": file_path}
|
||||
|
||||
# Reject activities whose average speed is implausible for the sport (e.g. a
|
||||
# car journey accidentally recorded). Surfaced to the upload UI as the reason.
|
||||
if parsed.get("rejected_reason"):
|
||||
return {"status": "skipped", "reason": parsed["rejected_reason"], "file": file_path}
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||
|
||||
@@ -128,6 +133,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
start_time=start_time,
|
||||
distance_m=parsed.get("distance_m"),
|
||||
duration_s=parsed.get("duration_s"),
|
||||
moving_time_s=parsed.get("moving_time_s"),
|
||||
elevation_gain_m=parsed.get("elevation_gain_m"),
|
||||
elevation_loss_m=parsed.get("elevation_loss_m"),
|
||||
avg_heart_rate=parsed.get("avg_heart_rate"),
|
||||
@@ -651,6 +657,10 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
|
||||
db.commit()
|
||||
|
||||
|
||||
class SyncCancelled(Exception):
|
||||
"""Raised inside a Garmin sync when the user has requested cancellation."""
|
||||
|
||||
|
||||
@celery_app.task(name="sync_garmin_connect_user")
|
||||
def sync_garmin_connect_user(user_id: int):
|
||||
"""Sync Garmin Connect data (activities + wellness) for one user."""
|
||||
@@ -661,6 +671,20 @@ def sync_garmin_connect_user(user_id: int):
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Cooperative-cancellation flag (set by POST /garmin-sync/cancel).
|
||||
cancel_key = f"garmin_sync_cancel:{user_id}"
|
||||
try:
|
||||
import redis as redis_lib
|
||||
_redis = redis_lib.Redis.from_url(settings.redis_url)
|
||||
except Exception:
|
||||
_redis = None
|
||||
|
||||
def _cancelled():
|
||||
try:
|
||||
return bool(_redis and _redis.exists(cancel_key))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
cfg = db.execute(
|
||||
select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)
|
||||
@@ -698,31 +722,51 @@ def sync_garmin_connect_user(user_id: int):
|
||||
errors = []
|
||||
|
||||
def _set_status(text):
|
||||
# Checked between items: abort the sync if cancellation was requested.
|
||||
if _cancelled():
|
||||
raise SyncCancelled()
|
||||
cfg.last_sync_status = text
|
||||
db.commit()
|
||||
|
||||
if sync_acts:
|
||||
_set_status("Syncing activities...")
|
||||
try:
|
||||
activities_queued = sync_activities(
|
||||
garmin, user_id, last_sync_at, db, settings.file_store_path,
|
||||
lookback_days=lookback,
|
||||
status_callback=_set_status,
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.append(f"activities: {exc}")
|
||||
try:
|
||||
if sync_acts:
|
||||
_set_status("Syncing activities...")
|
||||
try:
|
||||
activities_queued = sync_activities(
|
||||
garmin, user_id, last_sync_at, db, settings.file_store_path,
|
||||
lookback_days=lookback,
|
||||
status_callback=_set_status,
|
||||
)
|
||||
except SyncCancelled:
|
||||
raise
|
||||
except Exception as exc:
|
||||
errors.append(f"activities: {exc}")
|
||||
|
||||
if sync_well:
|
||||
_set_status("Syncing wellness...")
|
||||
if sync_well:
|
||||
_set_status("Syncing wellness...")
|
||||
try:
|
||||
wellness_days = sync_wellness(
|
||||
garmin, user_id, last_sync_at, db,
|
||||
lookback_days=lookback,
|
||||
status_callback=_set_status,
|
||||
)
|
||||
except SyncCancelled:
|
||||
raise
|
||||
except Exception as exc:
|
||||
errors.append(f"wellness: {exc}")
|
||||
db.rollback() # recover session so the final status commit can succeed
|
||||
except SyncCancelled:
|
||||
db.rollback()
|
||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||
cfg.last_sync_status = "Cancelled"
|
||||
db.commit()
|
||||
try:
|
||||
wellness_days = sync_wellness(
|
||||
garmin, user_id, last_sync_at, db,
|
||||
lookback_days=lookback,
|
||||
status_callback=_set_status,
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.append(f"wellness: {exc}")
|
||||
db.rollback() # recover session so the final status commit can succeed
|
||||
if _redis:
|
||||
_redis.delete(cancel_key)
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "cancelled",
|
||||
"activities_queued": activities_queued, "wellness_days": wellness_days}
|
||||
|
||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||
cfg.last_sync_status = (
|
||||
@@ -777,3 +821,47 @@ def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
|
||||
activity.hr_zones = new_zones
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
@celery_app.task(name="backfill_moving_time")
|
||||
def backfill_moving_time(user_id: int = None):
|
||||
"""Populate moving_time_s for existing FIT-sourced activities by re-reading the
|
||||
timer time from their stored source files. Idempotent — skips activities that
|
||||
already have a value or whose source file is missing/unreadable."""
|
||||
import os
|
||||
from app.services.fit_parser import parse_fit_file
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Activity
|
||||
from sqlalchemy import select
|
||||
|
||||
updated, skipped = 0, 0
|
||||
with SyncSessionLocal() as db:
|
||||
q = select(Activity).where(
|
||||
Activity.moving_time_s.is_(None),
|
||||
Activity.source_type == "fit",
|
||||
Activity.source_file.isnot(None),
|
||||
)
|
||||
if user_id is not None:
|
||||
q = q.where(Activity.user_id == user_id)
|
||||
activities = db.execute(q).scalars().all()
|
||||
|
||||
for activity in activities:
|
||||
path = activity.source_file
|
||||
if not path or not os.path.exists(path):
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
parsed = parse_fit_file(path)
|
||||
except Exception:
|
||||
skipped += 1
|
||||
continue
|
||||
mt = parsed.get("moving_time_s")
|
||||
if mt:
|
||||
activity.moving_time_s = mt
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok", "updated": updated, "skipped": skipped}
|
||||
|
||||
Reference in New Issue
Block a user