Add trend-range gating, vehicle filter, sync cancel, moving time, and UI fixes
Build and push images / validate (push) Successful in 9s
Build and push images / build-backend (push) Successful in 1m57s
Build and push images / build-worker (push) Successful in 50s
Build and push images / build-frontend (push) Successful in 24s

- 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:
2026-06-11 19:41:56 +01:00
parent 057eb9391a
commit ec87f68729
17 changed files with 569 additions and 132 deletions
+25 -3
View File
@@ -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. 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 ## Building and deploying
`docker-compose.yml` — build from source (dev/CI). `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 "<commit message>"` 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`. `./deploy.sh "<commit message>"` 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 ```bash
# Rebuild and restart from source: # Rebuild and restart from source:
docker compose build --no-cache 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 - 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. - `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` - `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/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route)
- `components/ui/RouteMiniMap` small Leaflet map used in route/segment cards - `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. 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`) | | `HTTP_PORT` | Host port for nginx (default: `80`) |
| `FILE_STORE_PATH` | Where uploaded FIT files are stored (default: `/data/files`) | | `FILE_STORE_PATH` | Where uploaded FIT files are stored (default: `/data/files`) |
| `BASE_URL` | Used for PocketID OAuth callback redirect URI | | `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_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |
## milevault_export/ ## milevault_export/
+1
View File
@@ -35,6 +35,7 @@ class ActivitySummary(BaseModel):
class ActivityDetail(ActivitySummary): class ActivityDetail(ActivitySummary):
end_time: Optional[datetime] end_time: Optional[datetime]
moving_time_s: Optional[float]
elevation_loss_m: Optional[float] elevation_loss_m: Optional[float]
max_heart_rate: Optional[float] max_heart_rate: Optional[float]
avg_power: Optional[float] avg_power: Optional[float]
+56
View File
@@ -13,6 +13,19 @@ from app.models.user import User, GarminConnectConfig
router = APIRouter() 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): class GarminConfigIn(BaseModel):
email: str email: str
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing. 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 from app.workers.tasks import sync_garmin_connect_user
task = sync_garmin_connect_user.delay(current_user.id) 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"} 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"}
+27 -4
View File
@@ -115,14 +115,21 @@ async def upload_garmin_export(
extract_dir = dest_dir / f"garmin_{dest.stem}" extract_dir = dest_dir / f"garmin_{dest.stem}"
task_ids = [] task_ids = []
with zipfile.ZipFile(dest) as zf: try:
extracted = _safe_extract(zf, extract_dir) 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: for path in extracted:
suffix = path.suffix.lower() suffix = path.suffix.lower()
if suffix == ".fit": if suffix == ".fit":
task = process_activity_file.delay(str(path), current_user.id, "fit") task = process_activity_file.delay(str(path), current_user.id, "fit")
task_ids.append(task.id) task_ids.append(task.id)
elif suffix == ".json":
has_health = True # Garmin wellness data is exported as JSON files
elif suffix == ".zip": elif suffix == ".zip":
# Garmin exports nest activity FIT files inside sub-zips # Garmin exports nest activity FIT files inside sub-zips
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip) # (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 = process_activity_file.delay(str(np), current_user.id, "fit")
task_ids.append(task.id) 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 # Queue health/wellness data extraction
health_task = process_garmin_health_zip.delay(str(dest), current_user.id) 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}" extract_dir = dest_dir / f"strava_{dest.stem}"
task_ids = [] task_ids = []
with zipfile.ZipFile(dest) as zf: try:
extracted = _safe_extract(zf, extract_dir) 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: for path in extracted:
suffix = path.suffix.lower() 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 = process_activity_file.delay(str(path), current_user.id, suffix[1:])
task_ids.append(task.id) 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 { return {
"status": "queued", "status": "queued",
"activity_tasks": len(task_ids), "activity_tasks": len(task_ids),
+9
View File
@@ -50,6 +50,15 @@ async def init_db():
except Exception as e: except Exception as e:
print(f"Column migration skipped: {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 # health_metrics columns added after initial creation
try: try:
async with engine.begin() as conn: async with engine.begin() as conn:
+2 -1
View File
@@ -92,7 +92,8 @@ class Activity(Base):
start_time = Column(DateTime(timezone=True), nullable=False, index=True) start_time = Column(DateTime(timezone=True), nullable=False, index=True)
end_time = Column(DateTime(timezone=True), nullable=True) end_time = Column(DateTime(timezone=True), nullable=True)
distance_m = Column(Float, 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_gain_m = Column(Float, nullable=True)
elevation_loss_m = Column(Float, nullable=True) elevation_loss_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True) avg_heart_rate = Column(Float, nullable=True)
+47 -10
View File
@@ -44,6 +44,33 @@ def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
return fv 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): def _bounding_box(coords):
if not coords: if not coords:
return None return None
@@ -210,12 +237,22 @@ def parse_fit_file(filepath: str) -> dict:
if start_time: if start_time:
name += " " + start_time.strftime("%Y-%m-%d") 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 { return {
"name": name, "name": name,
"sport_type": sport_type, "sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None, "start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")), "distance_m": total_dist,
"duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")), "duration_s": elapsed_s,
"moving_time_s": moving_s,
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")), "elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")), "elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")), "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_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")), "avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")), "normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
"avg_speed_ms": _sanitize_speed( "avg_speed_ms": avg_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")),
),
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed", "max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
"enhancedMaxSpeed", "enhanced_max_speed")), "enhancedMaxSpeed", "enhanced_max_speed")),
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")), "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, "polyline": encoded_polyline,
"bounding_box": bounding_box, "bounding_box": bounding_box,
"source_type": "fit", "source_type": "fit",
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
"data_points": normalized_points, "data_points": normalized_points,
"laps": normalized_laps, "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 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 duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
sport = track.type.lower() if track.type else "running" sport = track.type.lower() if track.type else "running"
gpx_avg_speed = (total_dist / duration) if (total_dist and duration) else None
return { return {
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}", "name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
"sport_type": sport, "start_time": start_time_str, "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, "elevation_gain_m": uphill, "elevation_loss_m": downhill,
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None, "avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
"max_heart_rate": max(hrs) if hrs else None, "max_heart_rate": max(hrs) if hrs else None,
"avg_cadence": None, "avg_power": None, "normalized_power": 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, "max_speed_ms": None, "avg_temperature_c": None, "calories": None,
"training_stress_score": None, "vo2max_estimate": None, "training_stress_score": None, "vo2max_estimate": None,
"polyline": encoded_polyline, "bounding_box": bounding_box, "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": [],
} }
+108 -20
View File
@@ -80,6 +80,11 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
if not parsed.get("start_time"): if not parsed.get("start_time"):
return {"status": "skipped", "reason": "no start_time", "file": file_path} 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: with SyncSessionLocal() as db:
start_time = datetime.fromisoformat(parsed["start_time"]) 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, start_time=start_time,
distance_m=parsed.get("distance_m"), distance_m=parsed.get("distance_m"),
duration_s=parsed.get("duration_s"), duration_s=parsed.get("duration_s"),
moving_time_s=parsed.get("moving_time_s"),
elevation_gain_m=parsed.get("elevation_gain_m"), elevation_gain_m=parsed.get("elevation_gain_m"),
elevation_loss_m=parsed.get("elevation_loss_m"), elevation_loss_m=parsed.get("elevation_loss_m"),
avg_heart_rate=parsed.get("avg_heart_rate"), 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() db.commit()
class SyncCancelled(Exception):
"""Raised inside a Garmin sync when the user has requested cancellation."""
@celery_app.task(name="sync_garmin_connect_user") @celery_app.task(name="sync_garmin_connect_user")
def sync_garmin_connect_user(user_id: int): def sync_garmin_connect_user(user_id: int):
"""Sync Garmin Connect data (activities + wellness) for one user.""" """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 sqlalchemy import select
from datetime import datetime, timezone 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: with SyncSessionLocal() as db:
cfg = db.execute( cfg = db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id) select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)
@@ -698,31 +722,51 @@ def sync_garmin_connect_user(user_id: int):
errors = [] errors = []
def _set_status(text): def _set_status(text):
# Checked between items: abort the sync if cancellation was requested.
if _cancelled():
raise SyncCancelled()
cfg.last_sync_status = text cfg.last_sync_status = text
db.commit() db.commit()
if sync_acts: try:
_set_status("Syncing activities...") if sync_acts:
try: _set_status("Syncing activities...")
activities_queued = sync_activities( try:
garmin, user_id, last_sync_at, db, settings.file_store_path, activities_queued = sync_activities(
lookback_days=lookback, garmin, user_id, last_sync_at, db, settings.file_store_path,
status_callback=_set_status, lookback_days=lookback,
) status_callback=_set_status,
except Exception as exc: )
errors.append(f"activities: {exc}") except SyncCancelled:
raise
except Exception as exc:
errors.append(f"activities: {exc}")
if sync_well: if sync_well:
_set_status("Syncing wellness...") _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: try:
wellness_days = sync_wellness( if _redis:
garmin, user_id, last_sync_at, db, _redis.delete(cancel_key)
lookback_days=lookback, except Exception:
status_callback=_set_status, pass
) return {"status": "cancelled",
except Exception as exc: "activities_queued": activities_queued, "wellness_days": wellness_days}
errors.append(f"wellness: {exc}")
db.rollback() # recover session so the final status commit can succeed
cfg.last_sync_at = datetime.now(timezone.utc) cfg.last_sync_at = datetime.now(timezone.utc)
cfg.last_sync_status = ( 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 activity.hr_zones = new_zones
db.commit() 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}
@@ -71,6 +71,25 @@ const dot = (color) => L.divIcon({
iconSize: [12, 12], iconAnchor: [6, 6], className: '', 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: '<div style="width:14px;height:14px;background:#22c55e;border:2px solid #fff;border-radius:50%;box-shadow:0 0 8px rgba(34,197,94,0.9)"></div>',
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) { function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
if (trackRef.current) { if (trackRef.current) {
trackRef.current.remove() trackRef.current.remove()
@@ -140,6 +159,7 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
const mapRef = useRef(null) const mapRef = useRef(null)
const mapInstanceRef = useRef(null) const mapInstanceRef = useRef(null)
const markerRef = useRef(null) const markerRef = useRef(null)
const segTargetRef = useRef(null)
const trackRef = useRef(null) const trackRef = useRef(null)
const tileLayerRef = useRef(null) const tileLayerRef = useRef(null)
const drawArgsRef = useRef({ polyline, dataPoints, sportType, colorMode }) 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 }) 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 () => { return () => {
mapInstanceRef.current?.remove() mapInstanceRef.current?.remove()
mapInstanceRef.current = null 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(() => { useEffect(() => {
if (!mapInstanceRef.current) return if (!mapInstanceRef.current) return
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street
@@ -27,11 +27,23 @@ function downsample(points, maxPoints = 500) {
return points.filter((_, i) => i % step === 0) 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 return dataPoints
.filter(p => p.timestamp) .filter(p => p.timestamp)
.map(p => { .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) { for (const key of activeMetrics) {
row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null 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 (!active || !payload?.length) return null
if (onHover) onHover(label) if (onHover) onHover(label)
return ( return (
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl"> <div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p> <p className="text-gray-400 mb-1">{useTimeAxis ? fmtSeconds(label) : `${(label / 1000).toFixed(2)} km`}</p>
{payload.map(entry => { {payload.map(entry => {
const metric = metrics.find(m => m.key === entry.dataKey) const metric = metrics.find(m => m.key === entry.dataKey)
if (!metric || entry.value == null) return null 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 }) { 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(() => const chartData = useMemo(() =>
downsample(buildChartData(dataPoints, activeMetrics)), downsample(buildChartData(dataPoints, activeMetrics, useTimeAxis)),
[dataPoints, activeMetrics] [dataPoints, activeMetrics, useTimeAxis]
) )
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key)) const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
@@ -119,10 +139,10 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics"> <ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics">
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis <XAxis
dataKey="distance_m" dataKey="x"
type="number" type="number"
domain={['dataMin', 'dataMax']} domain={['dataMin', 'dataMax']}
tickFormatter={v => `${(v / 1000).toFixed(1)}`} tickFormatter={v => useTimeAxis ? fmtSeconds(v) : `${(v / 1000).toFixed(1)}`}
tick={{ fontSize: 10, fill: '#6b7280' }} tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
@@ -146,7 +166,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
}} }}
/> />
<Tooltip <Tooltip
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />} content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} useTimeAxis={useTimeAxis} />}
isAnimationActive={false} isAnimationActive={false}
/> />
{metric.key === 'cadence' && sportType === 'running' ? ( {metric.key === 'cadence' && sportType === 'running' ? (
@@ -171,7 +191,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
</div> </div>
) )
})} })}
<p className="text-xs text-gray-600 text-center">Distance (km)</p> <p className="text-xs text-gray-600 text-center">{useTimeAxis ? 'Elapsed time (mm:ss)' : 'Distance (km)'}</p>
</div> </div>
) )
} }
@@ -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 { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../../utils/api' import api from '../../utils/api'
import { formatDuration, formatDistance } from '../../utils/format' import { formatDuration, formatDistance } from '../../utils/format'
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' } 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({ const { data } = useQuery({
queryKey: ['segment', segmentId], queryKey: ['segment', segmentId],
queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data), queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data),
}) })
if (!data) return <p className="text-xs text-gray-600 py-2">Loading</p> if (!data) return <p className="text-xs text-gray-600 py-2">Loading</p>
if (!data.leaderboard?.length) return <p className="text-xs text-gray-600 py-2">No efforts yet still matching.</p> if (!data.leaderboard?.length) return <p className="text-xs text-gray-600 py-2">No efforts yet still matching.</p>
const top = data.leaderboard.slice(0, 10)
const fastest = top[0].duration_s
return ( return (
<div className="space-y-0.5 py-1"> <table className="w-full text-sm mt-1 mb-2">
{data.leaderboard.map((e, i) => ( <thead>
<div key={e.activity_id} className="flex items-center gap-2 text-xs"> <tr className="text-xs text-gray-500 border-b border-gray-800">
<span className="w-5 text-right">{MEDALS[e.rank] || i + 1}</span> <th className="text-left pb-2 font-medium">#</th>
<span className="font-mono text-gray-200 w-14 text-right">{formatDuration(e.duration_s)}</span> <th className="text-left pb-2 font-medium">Activity</th>
<a href={`/activities/${e.activity_id}`} className="text-gray-400 hover:text-blue-400 truncate flex-1">{e.activity_name}</a> <th className="text-right pb-2 font-medium">Time</th>
</div> <th className="text-right pb-2 font-medium">Δ</th>
))} </tr>
</div> </thead>
<tbody>
{top.map((e) => {
const isCurrent = e.activity_id === activityId
const gap = gapLabel(e.duration_s - fastest)
return (
<tr
key={e.activity_id}
className={`border-b border-gray-800/50 transition-colors ${
isCurrent ? 'bg-emerald-500/15 hover:bg-emerald-500/20' : 'hover:bg-gray-800/30'
}`}
>
<td className={`py-2 ${e.rank === 1 ? 'text-yellow-400' : 'text-gray-400'}`}>
{e.rank === 1 ? '🏆' : e.rank}
</td>
<td className="py-2">
<Link
to={`/activities/${e.activity_id}`}
className={`hover:underline ${isCurrent ? 'text-emerald-300 font-medium' : 'text-gray-300'}`}
>
{e.activity_name}
</Link>
</td>
<td className={`py-2 text-right font-mono ${isCurrent ? 'text-emerald-300 font-semibold' : 'text-gray-200'}`}>
{formatDuration(e.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{gap == null ? '--' : gap}
</td>
</tr>
)
})}
</tbody>
</table>
) )
} }
export default function SegmentsPanel({ segments }) { export default function SegmentsPanel({ segments, activityId }) {
const qc = useQueryClient() const qc = useQueryClient()
const [open, setOpen] = useState(null) const [open, setOpen] = useState(null)
@@ -36,43 +80,66 @@ export default function SegmentsPanel({ segments }) {
} }
return ( return (
<div className="space-y-1"> <div className="overflow-x-auto">
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 text-xs text-gray-600 uppercase tracking-wide"> <table className="w-full text-sm">
<span className="flex-1">Segment</span> <thead>
<span className="w-14 text-right">This run</span> <tr className="text-xs text-gray-500 border-b border-gray-800">
<span className="w-14 text-right">Best</span> <th className="text-left pb-2 font-medium">Segment</th>
<span className="w-10 text-right">Place</span> <th className="text-right pb-2 font-medium">This run</th>
</div> <th className="text-right pb-2 font-medium">Best</th>
{segments.map(seg => { <th className="text-right pb-2 font-medium">Place</th>
const isPodium = seg.rank && seg.rank <= 3 <th className="pb-2" />
const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null </tr>
return ( </thead>
<div key={seg.segment_id} className="border-b border-gray-800/40"> <tbody>
<div className="flex items-center gap-3 py-1.5 text-sm"> {segments.map(seg => {
<button onClick={() => setOpen(open === seg.segment_id ? null : seg.segment_id)} const isPodium = seg.rank && seg.rank <= 3
className="flex-1 text-left text-gray-300 text-xs truncate hover:text-white"> const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null
{seg.name} const isOpen = open === seg.segment_id
<span className="text-gray-600 ml-2">{formatDistance(seg.distance_m)}</span> return (
</button> <Fragment key={seg.segment_id}>
<span className={`font-mono text-xs w-14 text-right ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}> <tr
{formatDuration(seg.duration_s)} className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
</span> >
<span className="font-mono text-xs w-14 text-right text-gray-500"> <td className="py-2">
{seg.best_s != null ? formatDuration(seg.best_s) : '--'} <button
</span> onClick={() => setOpen(isOpen ? null : seg.segment_id)}
<span className="w-10 text-right text-xs"> className="text-left text-gray-300 hover:text-white"
{isPodium >
? <span title="New podium time on this activity">{MEDALS[seg.rank]}</span> <span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
: delta != null {seg.name}
? <span className="text-red-400 font-mono">+{formatDuration(delta)}</span> <span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m)}</span>
: <span className="text-gray-700">--</span>} </button>
</span> </td>
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment"></button> <td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
</div> {formatDuration(seg.duration_s)}
{open === seg.segment_id && <Leaderboard segmentId={seg.segment_id} />} </td>
</div> <td className="py-2 text-right font-mono text-gray-500">
) {seg.best_s != null ? formatDuration(seg.best_s) : '--'}
})} </td>
<td className="py-2 text-right font-mono">
{isPodium
? <span title="Podium time on this activity" className="text-yellow-400">{MEDALS[seg.rank]}</span>
: delta != null
? <span className="text-gray-500">+{formatDuration(delta)}</span>
: <span className="text-gray-700">--</span>}
</td>
<td className="py-2 text-right">
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment"></button>
</td>
</tr>
{isOpen && (
<tr>
<td colSpan={5} className="bg-gray-950/40">
<Leaderboard segmentId={seg.segment_id} activityId={activityId} />
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div> </div>
) )
} }
+10 -2
View File
@@ -1,10 +1,10 @@
import { create } from 'zustand' import { create } from 'zustand'
import api from '../utils/api' 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) => const isTerminal = (s) =>
s.startsWith('OK') || s.startsWith('Partial') || s.startsWith('Auth error') || 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. // Map a Garmin sync status string to an approximate completion percentage.
export function syncProgressPct(status) { export function syncProgressPct(status) {
@@ -84,4 +84,12 @@ export const useSyncStore = create((set, get) => ({
get().stopPolling() get().stopPolling()
get().startPolling() get().startPolling()
}, },
cancel: async () => {
set({ status: 'Cancelling…' })
try {
await api.post('/garmin-sync/cancel')
} catch { /* ignore — poll will reflect the true state */ }
get().poll()
},
})) }))
+19 -8
View File
@@ -143,7 +143,11 @@ export default function ActivityDetailPage() {
{/* Stats — all on one row */} {/* Stats — all on one row */}
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3"> <div className="grid grid-cols-5 lg:grid-cols-10 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} /> <StatCard label="Distance" value={formatDistance(activity.distance_m)} />
<StatCard label="Time" value={formatDuration(activity.duration_s)} /> <StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
sub={activity.moving_time_s ? 'moving' : undefined} />
{activity.moving_time_s != null && Math.abs(activity.moving_time_s - (activity.duration_s ?? 0)) >= 1 && (
<StatCard label="Elapsed" value={formatDuration(activity.duration_s)} />
)}
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} /> <StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} /> <StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" /> <StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
@@ -162,7 +166,8 @@ export default function ActivityDetailPage() {
</div> </div>
)} )}
{/* Map with controls */} {/* Map with controls — only when the activity has a GPS track */}
{activity.polyline && activity.distance_m > 0 ? (
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800"> <div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
{/* Map toolbar */} {/* Map toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800"> <div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
@@ -265,6 +270,11 @@ export default function ActivityDetailPage() {
</div> </div>
)} )}
</div> </div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 flex items-center justify-center text-gray-600 text-sm">
No GPS track for this activity
</div>
)}
{/* Metric timeline */} {/* Metric timeline */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
@@ -300,25 +310,26 @@ export default function ActivityDetailPage() {
)} )}
</div> </div>
{/* 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) || (actSegments && actSegments.length > 0) || (routeBoard && routeBoard.top?.length > 0)) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="flex flex-wrap gap-4 items-start">
{laps && laps.length > 0 && ( {laps && laps.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} /> <LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
</div> </div>
)} )}
{routeBoard && routeBoard.top?.length > 0 && ( {routeBoard && routeBoard.top?.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Route Top 10 Times</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Route Top 10 Times</h3>
<RouteLeaderboard data={routeBoard} /> <RouteLeaderboard data={routeBoard} />
</div> </div>
)} )}
{actSegments && actSegments.length > 0 && ( {actSegments && actSegments.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
<SegmentsPanel segments={actSegments} /> <SegmentsPanel segments={actSegments} activityId={Number(id)} />
</div> </div>
)} )}
</div> </div>
+2 -1
View File
@@ -212,7 +212,8 @@ export default function DashboardPage() {
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link> <Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
</div> </div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<StatCard label="Steps today" value={health.steps != null ? health.steps.toLocaleString() : '--'} accent="green" sub="goal 10,000" />
<StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" /> <StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
<StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" /> <StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
<StatCard label="Resting HR" value={formatHeartRate(health.resting_hr)} accent="red" /> <StatCard label="Resting HR" value={formatHeartRate(health.resting_hr)} accent="red" />
+31 -11
View File
@@ -4,7 +4,7 @@ import {
AreaChart, Area, BarChart, Bar, ReferenceLine, ReferenceArea, AreaChart, Area, BarChart, Bar, ReferenceLine, ReferenceArea,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
} from 'recharts' } from 'recharts'
import { format, subDays } from 'date-fns' import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns'
import api from '../utils/api' import api from '../utils/api'
import { formatSleep, sportIcon } from '../utils/format' import { formatSleep, sportIcon } from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery' import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
@@ -885,6 +885,18 @@ export default function HealthPage() {
[allDays], [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(() => { const selectedDay = useMemo(() => {
if (!selectedDateStr) return allDaysSorted[0] || null if (!selectedDateStr) return allDaysSorted[0] || null
return allDaysSorted.find(d => d.date === selectedDateStr) || null return allDaysSorted.find(d => d.date === selectedDateStr) || null
@@ -970,16 +982,24 @@ export default function HealthPage() {
<p className="text-xs text-gray-600">Click any point to load that day above</p> <p className="text-xs text-gray-600">Click any point to load that day above</p>
</div> </div>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{RANGES.map(({ label, days }) => ( {RANGES.map(({ label, days }, i) => {
<button key={label} onClick={() => setRangeDays(days)} const disabled = i > maxEnabledRangeIdx
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${ return (
rangeDays === days <button key={label}
? 'bg-blue-600 border-blue-600 text-white' onClick={() => !disabled && setRangeDays(days)}
: 'border-gray-700 text-gray-400 hover:text-white' disabled={disabled}
}`}> title={disabled ? 'Not enough history for this range' : undefined}
{label} className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
</button> disabled
))} ? 'border-gray-800 text-gray-700 opacity-40 cursor-not-allowed'
: rangeDays === days
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white'
}`}>
{label}
</button>
)
})}
</div> </div>
</div> </div>
+14 -6
View File
@@ -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 [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
const [gcSaved, setGcSaved] = useState(false) const [gcSaved, setGcSaved] = useState(false)
const [gcError, setGcError] = useState('') 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) const gcFormLoaded = useRef(false)
useEffect(() => { useEffect(() => {
if (garminConfig?.connected && !gcFormLoaded.current) { if (garminConfig?.connected && !gcFormLoaded.current) {
@@ -423,11 +423,19 @@ export default function ProfilePage() {
</span> </span>
))} ))}
</div> </div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden"> <div className="flex items-center gap-2">
<div <div className="h-2 flex-1 bg-gray-800 rounded-full overflow-hidden">
className="h-full bg-blue-500 rounded-full transition-all duration-700" <div
style={{ width: `${pct}%` }} className="h-full bg-blue-500 rounded-full transition-all duration-700"
/> style={{ width: `${pct}%` }}
/>
</div>
<button
onClick={cancelSync}
disabled={status.startsWith('Cancel')}
className="text-red-400 hover:text-red-300 disabled:opacity-50 text-xs font-medium px-2 py-1 rounded-lg border border-red-500/40 hover:border-red-400 transition-colors whitespace-nowrap">
Cancel
</button>
</div> </div>
<p className="text-xs text-blue-400"> <p className="text-xs text-blue-400">
{status || 'Starting sync…'} {status || 'Starting sync…'}
+26 -7
View File
@@ -16,10 +16,17 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
if (data.status === 'SUCCESS' || data.status === 'FAILURE') { if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
clearInterval(intervalsRef.current[taskId]) clearInterval(intervalsRef.current[taskId])
delete 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 => 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: ['activities'] })
queryClient.invalidateQueries({ queryKey: ['health-summary'] }) queryClient.invalidateQueries({ queryKey: ['health-summary'] })
queryClient.invalidateQueries({ queryKey: ['health-metrics'] }) queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
@@ -50,6 +57,10 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
pollTask(data.task_id) 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) => { const onDrop = useCallback((accepted) => {
@@ -65,6 +76,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
function StatusBadge({ status }) { function StatusBadge({ status }) {
if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse"> Processing</span> if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse"> Processing</span>
if (status === 'done') return <span className="ml-2 text-green-400"> Done</span> if (status === 'done') return <span className="ml-2 text-green-400"> Done</span>
if (status === 'skipped') return <span className="ml-2 text-amber-400"> Skipped</span>
if (status === 'failed') return <span className="ml-2 text-red-400"> Failed</span> if (status === 'failed') return <span className="ml-2 text-red-400"> Failed</span>
return <span className="ml-2 text-green-400"> Queued</span> return <span className="ml-2 text-green-400"> Queued</span>
} }
@@ -107,12 +119,19 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
{tasks.length > 0 && ( {tasks.length > 0 && (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{tasks.map((task, i) => ( {tasks.map((task, i) => (
<div key={i} className="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2"> <div key={i} className="bg-gray-800 rounded-lg px-3 py-2">
<span className="text-gray-300 truncate flex-1">{task.file}</span> <div className="flex items-center justify-between text-xs">
{task.activity_tasks !== undefined && ( <span className="text-gray-300 truncate flex-1">{task.file}</span>
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span> {task.activity_tasks !== undefined && (
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
)}
<StatusBadge status={task.status} />
</div>
{task.reason && (task.status === 'skipped' || task.status === 'failed') && (
<p className={`text-xs mt-1 ${task.status === 'skipped' ? 'text-amber-400/80' : 'text-red-400/80'}`}>
{task.reason}
</p>
)} )}
<StatusBadge status={task.status} />
</div> </div>
))} ))}
</div> </div>