From ec87f687297ae13e20ad919a5b29eb9b29081995 Mon Sep 17 00:00:00 2001 From: owain Date: Thu, 11 Jun 2026 19:41:56 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 28 ++- backend/app/api/activities.py | 1 + backend/app/api/garmin_sync.py | 56 ++++++ backend/app/api/upload.py | 31 +++- backend/app/main.py | 9 + backend/app/models/user.py | 3 +- backend/app/services/fit_parser.py | 57 ++++-- backend/app/workers/tasks.py | 128 +++++++++++--- .../src/components/activity/ActivityMap.jsx | 46 +++++ .../components/activity/MetricTimeline.jsx | 40 +++-- .../src/components/activity/SegmentsPanel.jsx | 165 ++++++++++++------ frontend/src/hooks/useSync.js | 12 +- frontend/src/pages/ActivityDetailPage.jsx | 27 ++- frontend/src/pages/DashboardPage.jsx | 3 +- frontend/src/pages/HealthPage.jsx | 42 +++-- frontend/src/pages/ProfilePage.jsx | 20 ++- frontend/src/pages/UploadPage.jsx | 33 +++- 17 files changed, 569 insertions(+), 132 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c6be4a9..04b64a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,21 @@ The app is served on port 80 by nginx, which proxies `/api/*` to the backend (po There are no automated tests. Verification is done by running the app and observing behaviour. +## Debugging running containers + +The production stack runs in `~/milevault_docker` with fixed container names. Use these to investigate issues — never patch the running files: + +```bash +# Tail logs from a specific container +docker logs -f milevault_backend +docker logs -f milevault_worker +docker logs -f milevault_db + +# Run a one-off query or command inside a container +docker exec milevault_backend python -c "from app.core.config import settings; print(settings.base_url)" +docker exec -it milevault_db psql -U milevault -d milevault +``` + ## Building and deploying `docker-compose.yml` — build from source (dev/CI). @@ -40,6 +55,10 @@ The Gitea Actions workflow (`.gitea/workflows/build.yml`) auto-builds and pushes `./deploy.sh ""` is the normal dev loop here: it commits everything, pushes to `main` (triggering the image build), and stops the running stack in `../milevault_docker`. After the build finishes, run `docker compose pull && docker compose up -d` there. This matches the repo rule: fix files in `~/milevault`, push to git — never patch the running containers in `~/milevault_docker`. +**CI validation**: The build workflow runs a `validate` job before building images. It will fail if `@polyline-codec` appears in `frontend/package.json` or if `npm ci` is used in `frontend/Dockerfile` (no lockfile exists — always use `npm install`). Fix these before pushing. + +**`VITE_MAPBOX_TOKEN`** is baked empty by the CI build (`build-args: VITE_MAPBOX_TOKEN=`), so satellite tiles are disabled in all pre-built images. To enable them, rebuild locally with the token set in `.env`. + ```bash # Rebuild and restart from source: docker compose build --no-cache @@ -94,8 +113,8 @@ docker compose -f docker-compose.deploy.yml up -d - TanStack Query (`@tanstack/react-query`) handles all server-state fetching and caching; Zustand is used only for auth state - `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc. - `pages/` — one file per route: `Dashboard`, `Activities`, `ActivityDetail`, `Routes`, `Records`, `Health`, `Upload`, `Profile`, `Users`, `Login` -- `components/activity/` — `ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts) -- `components/ui/RouteMiniMap` — small Leaflet map used in route/segment cards +- `components/activity/` — `ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route) +- `components/ui/` — `Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards) The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time. @@ -115,8 +134,11 @@ Required in `.env` (or passed to Docker Compose): | `HTTP_PORT` | Host port for nginx (default: `80`) | | `FILE_STORE_PATH` | Where uploaded FIT files are stored (default: `/data/files`) | | `BASE_URL` | Used for PocketID OAuth callback redirect URI | -| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer | +| `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) | +| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer (baked at build time) | +| `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect (default: `30`) | | `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC | +| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group | ## milevault_export/ diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index 477b4cf..089a8ee 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -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] diff --git a/backend/app/api/garmin_sync.py b/backend/app/api/garmin_sync.py index 2563ecb..819aa30 100644 --- a/backend/app/api/garmin_sync.py +++ b/backend/app/api/garmin_sync.py @@ -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"} diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 674a30d..6caf851 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -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), diff --git a/backend/app/main.py b/backend/app/main.py index 9006d5d..681c272 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -50,6 +50,15 @@ async def init_db(): except Exception as e: print(f"Column migration skipped: {e}") + # activities.moving_time_s column added after initial creation (timer time) + try: + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE activities ADD COLUMN IF NOT EXISTS moving_time_s FLOAT" + )) + except Exception as e: + print(f"activities.moving_time_s column migration skipped: {e}") + # health_metrics columns added after initial creation try: async with engine.begin() as conn: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index bb644fc..ec15a25 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -92,7 +92,8 @@ class Activity(Base): start_time = Column(DateTime(timezone=True), nullable=False, index=True) end_time = Column(DateTime(timezone=True), nullable=True) distance_m = Column(Float, nullable=True) - duration_s = Column(Float, nullable=True) + duration_s = Column(Float, nullable=True) # total elapsed time (wall clock, incl. pauses) + moving_time_s = Column(Float, nullable=True) # timer time — excludes paused periods elevation_gain_m = Column(Float, nullable=True) elevation_loss_m = Column(Float, nullable=True) avg_heart_rate = Column(Float, nullable=True) diff --git a/backend/app/services/fit_parser.py b/backend/app/services/fit_parser.py index 543b1e7..4a5950e 100644 --- a/backend/app/services/fit_parser.py +++ b/backend/app/services/fit_parser.py @@ -44,6 +44,33 @@ def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]: return fv +# Conservative average-speed ceilings (m/s) above which an activity was almost +# certainly recorded in a vehicle rather than under human power. Sports not +# listed fall back to the generous default. +_VEHICLE_SPEED_CEILINGS = { + "running": 8.0, # ~28.8 km/h — well above elite sprint pace sustained + "walking": 8.0, + "hiking": 8.0, + "cycling": 22.0, # ~79 km/h — beyond sustained amateur cycling +} +_VEHICLE_SPEED_DEFAULT = 25.0 # ~90 km/h + + +def _vehicle_reason(sport_type, avg_speed_ms, dist_m=None, dur_s=None) -> Optional[str]: + """Return a human-readable reason if the average speed is implausibly fast for + the sport (i.e. the 'activity' looks like car/vehicle travel), else None.""" + speed = _safe_float(avg_speed_ms) + if speed is None and dist_m and dur_s and float(dur_s) > 0: + speed = float(dist_m) / float(dur_s) + if speed is None or speed <= 0: + return None + ceiling = _VEHICLE_SPEED_CEILINGS.get(sport_type, _VEHICLE_SPEED_DEFAULT) + if speed > ceiling: + return (f"Looks like vehicle travel — average speed {speed * 3.6:.0f} km/h " + f"exceeds the plausible limit for {sport_type}") + return None + + def _bounding_box(coords): if not coords: return None @@ -210,12 +237,22 @@ def parse_fit_file(filepath: str) -> dict: if start_time: name += " " + start_time.strftime("%Y-%m-%d") + total_dist = _safe_float(get(session_data, "totalDistance", "total_distance")) + elapsed_s = _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")) + # Timer time = time the device was actively recording (excludes auto/manual pauses). + moving_s = _safe_float(get(session_data, "totalTimerTime", "total_timer_time")) + avg_speed = _sanitize_speed( + get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"), + dist_m=total_dist, dur_s=elapsed_s, + ) + return { "name": name, "sport_type": sport_type, "start_time": start_time.isoformat() if start_time else None, - "distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")), - "duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")), + "distance_m": total_dist, + "duration_s": elapsed_s, + "moving_time_s": moving_s, "elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")), "elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")), "avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")), @@ -223,11 +260,7 @@ def parse_fit_file(filepath: str) -> dict: "avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")), "avg_power": _safe_float(get(session_data, "avgPower", "avg_power")), "normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")), - "avg_speed_ms": _sanitize_speed( - get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"), - dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")), - dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")), - ), + "avg_speed_ms": avg_speed, "max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed", "enhancedMaxSpeed", "enhanced_max_speed")), "avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")), @@ -239,6 +272,7 @@ def parse_fit_file(filepath: str) -> dict: "polyline": encoded_polyline, "bounding_box": bounding_box, "source_type": "fit", + "rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s), "data_points": normalized_points, "laps": normalized_laps, } @@ -310,20 +344,23 @@ def parse_gpx_file(filepath: str) -> dict: end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None sport = track.type.lower() if track.type else "running" + gpx_avg_speed = (total_dist / duration) if (total_dist and duration) else None return { "name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}", "sport_type": sport, "start_time": start_time_str, - "distance_m": total_dist, "duration_s": duration, + "distance_m": total_dist, "duration_s": duration, "moving_time_s": None, "elevation_gain_m": uphill, "elevation_loss_m": downhill, "avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None, "max_heart_rate": max(hrs) if hrs else None, "avg_cadence": None, "avg_power": None, "normalized_power": None, - "avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None, + "avg_speed_ms": gpx_avg_speed, "max_speed_ms": None, "avg_temperature_c": None, "calories": None, "training_stress_score": None, "vo2max_estimate": None, "polyline": encoded_polyline, "bounding_box": bounding_box, - "source_type": "gpx", "data_points": data_points, "laps": [], + "source_type": "gpx", + "rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration), + "data_points": data_points, "laps": [], } diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 682c74e..51ad184 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -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} diff --git a/frontend/src/components/activity/ActivityMap.jsx b/frontend/src/components/activity/ActivityMap.jsx index 3e4216e..6a9fd0e 100644 --- a/frontend/src/components/activity/ActivityMap.jsx +++ b/frontend/src/components/activity/ActivityMap.jsx @@ -71,6 +71,25 @@ const dot = (color) => L.divIcon({ iconSize: [12, 12], iconAnchor: [6, 6], className: '', }) +// Pulsing target dot shown under the cursor while drawing a segment, so the user +// can see exactly which track point a click will snap to. +const SEG_TARGET_ICON = L.divIcon({ + html: '
', + iconSize: [14, 14], iconAnchor: [7, 7], className: '', +}) + +// Nearest recorded GPS point to a lat/lng (squared planar distance is fine at +// these scales). Mirrors nearestDistance() in ActivityDetailPage. +function nearestPoint(points, lat, lng) { + let best = null, bestD = Infinity + for (const p of points) { + if (p.latitude == null || p.longitude == null) continue + const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2 + if (d < bestD) { bestD = d; best = p } + } + return best +} + function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) { if (trackRef.current) { trackRef.current.remove() @@ -140,6 +159,7 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo const mapRef = useRef(null) const mapInstanceRef = useRef(null) const markerRef = useRef(null) + const segTargetRef = useRef(null) const trackRef = useRef(null) const tileLayerRef = useRef(null) const drawArgsRef = useRef({ polyline, dataPoints, sportType, colorMode }) @@ -165,12 +185,38 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng }) }) + // While in segment-create mode, show a target dot snapped to the nearest + // track point so the user can see what a click will select. + mapInstanceRef.current.on('mousemove', (e) => { + const pts = drawArgsRef.current.dataPoints + if (!clickRef.current || !pts?.length) return + const np = nearestPoint(pts, e.latlng.lat, e.latlng.lng) + if (!np) return + if (segTargetRef.current) { + segTargetRef.current.setLatLng([np.latitude, np.longitude]) + } else { + segTargetRef.current = L.marker([np.latitude, np.longitude], + { icon: SEG_TARGET_ICON, interactive: false }).addTo(mapInstanceRef.current) + } + }) + mapInstanceRef.current.on('mouseout', () => { + if (segTargetRef.current) { segTargetRef.current.remove(); segTargetRef.current = null } + }) + return () => { mapInstanceRef.current?.remove() mapInstanceRef.current = null } }, []) + // Clear the target dot when leaving segment-create mode. + useEffect(() => { + if (!onMapClick && segTargetRef.current) { + segTargetRef.current.remove() + segTargetRef.current = null + } + }, [onMapClick]) + useEffect(() => { if (!mapInstanceRef.current) return const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street diff --git a/frontend/src/components/activity/MetricTimeline.jsx b/frontend/src/components/activity/MetricTimeline.jsx index 3418f10..ebf124a 100644 --- a/frontend/src/components/activity/MetricTimeline.jsx +++ b/frontend/src/components/activity/MetricTimeline.jsx @@ -27,11 +27,23 @@ function downsample(points, maxPoints = 500) { return points.filter((_, i) => i % step === 0) } -function buildChartData(dataPoints, activeMetrics) { +// mm:ss label for the time-based X-axis (stationary/indoor activities). +function fmtSeconds(s) { + const m = Math.floor(s / 60) + return `${m}:${String(Math.floor(s % 60)).padStart(2, '0')}` +} + +function buildChartData(dataPoints, activeMetrics, useTimeAxis) { + const base = useTimeAxis + ? new Date(dataPoints.find(p => p.timestamp)?.timestamp || 0).getTime() + : 0 return dataPoints .filter(p => p.timestamp) .map(p => { - const row = { distance_m: p.distance_m ?? 0 } + const x = useTimeAxis + ? (new Date(p.timestamp).getTime() - base) / 1000 + : (p.distance_m ?? 0) + const row = { x } for (const key of activeMetrics) { row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null } @@ -39,12 +51,12 @@ function buildChartData(dataPoints, activeMetrics) { }) } -const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => { +const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, useTimeAxis }) => { if (!active || !payload?.length) return null if (onHover) onHover(label) return (
-

{(label / 1000).toFixed(2)} km

+

{useTimeAxis ? fmtSeconds(label) : `${(label / 1000).toFixed(2)} km`}

{payload.map(entry => { const metric = metrics.find(m => m.key === entry.dataKey) if (!metric || entry.value == null) return null @@ -68,9 +80,17 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) } export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) { + // Stationary/indoor activities (HIIT, strength, trainer) record no distance, so + // plotting against distance collapses every sample onto x=0. Fall back to an + // elapsed-time X-axis when there's no distance spread. + const useTimeAxis = useMemo( + () => !dataPoints.some(p => p.distance_m != null && p.distance_m > 0), + [dataPoints] + ) + const chartData = useMemo(() => - downsample(buildChartData(dataPoints, activeMetrics)), - [dataPoints, activeMetrics] + downsample(buildChartData(dataPoints, activeMetrics, useTimeAxis)), + [dataPoints, activeMetrics, useTimeAxis] ) const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key)) @@ -119,10 +139,10 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH `${(v / 1000).toFixed(1)}`} + tickFormatter={v => useTimeAxis ? fmtSeconds(v) : `${(v / 1000).toFixed(1)}`} tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} @@ -146,7 +166,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH }} /> } + content={} isAnimationActive={false} /> {metric.key === 'cadence' && sportType === 'running' ? ( @@ -171,7 +191,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
) })} -

Distance (km)

+

{useTimeAxis ? 'Elapsed time (mm:ss)' : 'Distance (km)'}

) } diff --git a/frontend/src/components/activity/SegmentsPanel.jsx b/frontend/src/components/activity/SegmentsPanel.jsx index f2d4e3d..5cbd6c6 100644 --- a/frontend/src/components/activity/SegmentsPanel.jsx +++ b/frontend/src/components/activity/SegmentsPanel.jsx @@ -1,31 +1,75 @@ -import { useState } from 'react' +import { useState, Fragment } from 'react' +import { Link } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import api from '../../utils/api' import { formatDuration, formatDistance } from '../../utils/format' const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' } -function Leaderboard({ segmentId }) { +// Compact +M:SS gap label (fastest effort shows nothing) — mirrors RouteLeaderboard. +function gapLabel(gapS) { + if (gapS == null || gapS <= 0.5) return null + return `+${formatDuration(gapS)}` +} + +// Top-10 leaderboard for a single segment, styled to match RouteLeaderboard. +function Leaderboard({ segmentId, activityId }) { const { data } = useQuery({ queryKey: ['segment', segmentId], queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data), }) if (!data) return

Loading…

if (!data.leaderboard?.length) return

No efforts yet — still matching.

+ + const top = data.leaderboard.slice(0, 10) + const fastest = top[0].duration_s return ( -
- {data.leaderboard.map((e, i) => ( -
- {MEDALS[e.rank] || i + 1} - {formatDuration(e.duration_s)} - {e.activity_name} -
- ))} -
+ + + + + + + + + + + {top.map((e) => { + const isCurrent = e.activity_id === activityId + const gap = gapLabel(e.duration_s - fastest) + return ( + + + + + + + ) + })} + +
#ActivityTimeΔ
+ {e.rank === 1 ? '🏆' : e.rank} + + + {e.activity_name} + + + {formatDuration(e.duration_s)} + + {gap == null ? '--' : gap} +
) } -export default function SegmentsPanel({ segments }) { +export default function SegmentsPanel({ segments, activityId }) { const qc = useQueryClient() const [open, setOpen] = useState(null) @@ -36,43 +80,66 @@ export default function SegmentsPanel({ segments }) { } return ( -
-
- Segment - This run - Best - Place -
- {segments.map(seg => { - const isPodium = seg.rank && seg.rank <= 3 - const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null - return ( -
-
- - - {formatDuration(seg.duration_s)} - - - {seg.best_s != null ? formatDuration(seg.best_s) : '--'} - - - {isPodium - ? {MEDALS[seg.rank]} - : delta != null - ? +{formatDuration(delta)} - : --} - - -
- {open === seg.segment_id && } -
- ) - })} +
+ + + + + + + + + + + {segments.map(seg => { + const isPodium = seg.rank && seg.rank <= 3 + const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null + const isOpen = open === seg.segment_id + return ( + + + + + + + + + {isOpen && ( + + + + )} + + ) + })} + +
SegmentThis runBestPlace +
+ + + {formatDuration(seg.duration_s)} + + {seg.best_s != null ? formatDuration(seg.best_s) : '--'} + + {isPodium + ? {MEDALS[seg.rank]} + : delta != null + ? +{formatDuration(delta)} + : --} + + +
+ +
) } diff --git a/frontend/src/hooks/useSync.js b/frontend/src/hooks/useSync.js index 233a458..ccd5c1f 100644 --- a/frontend/src/hooks/useSync.js +++ b/frontend/src/hooks/useSync.js @@ -1,10 +1,10 @@ import { create } from 'zustand' import api from '../utils/api' -// A status string is "terminal" when the sync has finished (success, partial, or error). +// A status string is "terminal" when the sync has finished (success, partial, error, or cancelled). const isTerminal = (s) => s.startsWith('OK') || s.startsWith('Partial') || s.startsWith('Auth error') || - s.startsWith('Credentials') || s.startsWith('Connected') + s.startsWith('Credentials') || s.startsWith('Connected') || s.startsWith('Cancelled') // Map a Garmin sync status string to an approximate completion percentage. export function syncProgressPct(status) { @@ -84,4 +84,12 @@ export const useSyncStore = create((set, get) => ({ get().stopPolling() get().startPolling() }, + + cancel: async () => { + set({ status: 'Cancelling…' }) + try { + await api.post('/garmin-sync/cancel') + } catch { /* ignore — poll will reflect the true state */ } + get().poll() + }, })) diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index 0b6d8a3..81171ac 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -143,7 +143,11 @@ export default function ActivityDetailPage() { {/* Stats — all on one row */}
- + + {activity.moving_time_s != null && Math.abs(activity.moving_time_s - (activity.duration_s ?? 0)) >= 1 && ( + + )} @@ -162,7 +166,8 @@ export default function ActivityDetailPage() {
)} - {/* Map with controls */} + {/* Map with controls — only when the activity has a GPS track */} + {activity.polyline && activity.distance_m > 0 ? (
{/* Map toolbar */}
@@ -265,6 +270,11 @@ export default function ActivityDetailPage() {
)}
+ ) : ( +
+ No GPS track for this activity +
+ )} {/* Metric timeline */}
@@ -300,25 +310,26 @@ export default function ActivityDetailPage() { )}
- {/* Laps + Route leaderboard + Segments side by side */} + {/* Laps · Routes · Segments — on one row, each shrinking to fit and + expanding to fill the width when fewer are present. */} {((laps && laps.length > 0) || (actSegments && actSegments.length > 0) || (routeBoard && routeBoard.top?.length > 0)) && ( -
+
{laps && laps.length > 0 && ( -
+

Laps

)} {routeBoard && routeBoard.top?.length > 0 && ( -
+

Route — Top 10 Times

)} {actSegments && actSegments.length > 0 && ( -
+

Segments

- +
)}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index f6c34c5..accaa33 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -212,7 +212,8 @@ export default function DashboardPage() { + Import data
-
+
+ diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 92d3476..f3ed7f0 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -4,7 +4,7 @@ import { AreaChart, Area, BarChart, Bar, ReferenceLine, ReferenceArea, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, } from 'recharts' -import { format, subDays } from 'date-fns' +import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns' import api from '../utils/api' import { formatSleep, sportIcon } from '../utils/format' import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery' @@ -885,6 +885,18 @@ export default function HealthPage() { [allDays], ) + // Disable trend ranges that reach further back than the data goes. Keep every + // range up to and including the first one that already covers the full history + // enabled; ranges beyond that would only show the same (full) data. While the + // history is still loading we leave all ranges enabled. + const maxEnabledRangeIdx = useMemo(() => { + if (!allDaysSorted.length) return RANGES.length - 1 + const oldest = allDaysSorted[allDaysSorted.length - 1].date + const span = differenceInCalendarDays(new Date(), parseISO(oldest)) + const idx = RANGES.findIndex(r => r.days >= span) + return idx === -1 ? RANGES.length - 1 : idx + }, [allDaysSorted]) + const selectedDay = useMemo(() => { if (!selectedDateStr) return allDaysSorted[0] || null return allDaysSorted.find(d => d.date === selectedDateStr) || null @@ -970,16 +982,24 @@ export default function HealthPage() {

Click any point to load that day above

- {RANGES.map(({ label, days }) => ( - - ))} + {RANGES.map(({ label, days }, i) => { + const disabled = i > maxEnabledRangeIdx + return ( + + ) + })}
diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index e433c0f..3722416 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -140,7 +140,7 @@ export default function ProfilePage() { const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' }) const [gcSaved, setGcSaved] = useState(false) const [gcError, setGcError] = useState('') - const { inProgress: gcSyncing, status: syncStatus, trigger: triggerSync } = useSyncStore() + const { inProgress: gcSyncing, status: syncStatus, trigger: triggerSync, cancel: cancelSync } = useSyncStore() const gcFormLoaded = useRef(false) useEffect(() => { if (garminConfig?.connected && !gcFormLoaded.current) { @@ -423,11 +423,19 @@ export default function ProfilePage() { ))}
-
-
+
+
+
+
+

{status || 'Starting sync…'} diff --git a/frontend/src/pages/UploadPage.jsx b/frontend/src/pages/UploadPage.jsx index 96a5924..5d04713 100644 --- a/frontend/src/pages/UploadPage.jsx +++ b/frontend/src/pages/UploadPage.jsx @@ -16,10 +16,17 @@ function UploadZone({ title, description, accept, endpoint, icon }) { 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 === 'SUCCESS' ? 'done' : 'failed' } : 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') { + if (data.status === 'SUCCESS' && !skipped) { queryClient.invalidateQueries({ queryKey: ['activities'] }) queryClient.invalidateQueries({ queryKey: ['health-summary'] }) queryClient.invalidateQueries({ queryKey: ['health-metrics'] }) @@ -50,6 +57,10 @@ function UploadZone({ title, description, accept, endpoint, icon }) { 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) => { @@ -65,6 +76,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) { function StatusBadge({ status }) { if (status === 'processing') return ⏳ Processing if (status === 'done') return ✓ Done + if (status === 'skipped') return ⚠ Skipped if (status === 'failed') return ✗ Failed return ✓ Queued } @@ -107,12 +119,19 @@ function UploadZone({ title, description, accept, endpoint, icon }) { {tasks.length > 0 && (

{tasks.map((task, i) => ( -
- {task.file} - {task.activity_tasks !== undefined && ( - {task.activity_tasks} activities queued +
+
+ {task.file} + {task.activity_tasks !== undefined && ( + {task.activity_tasks} activities queued + )} + +
+ {task.reason && (task.status === 'skipped' || task.status === 'failed') && ( +

+ {task.reason} +

)} -
))}