Add trend-range gating, vehicle filter, sync cancel, moving time, and UI fixes
- Grey out trend ranges beyond available health history - Reject implausibly fast (vehicle) activities on upload with feedback - Add cancel button + cooperative cancellation for Garmin sync - Show daily steps prominently on the dashboard - Clear errors for malformed/empty upload ZIPs - Snap-target dot when drawing a segment on the map - Time-axis fallback for stationary/HIIT HR timelines; hide map when no GPS - Parse and display moving time (timer) vs elapsed; backfill task - Restyle SegmentsPanel like RouteLeaderboard; Laps/Routes/Segments on one row Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 "<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
|
||||
# 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/
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class ActivitySummary(BaseModel):
|
||||
|
||||
class ActivityDetail(ActivitySummary):
|
||||
end_time: Optional[datetime]
|
||||
moving_time_s: Optional[float]
|
||||
elevation_loss_m: Optional[float]
|
||||
max_heart_rate: Optional[float]
|
||||
avg_power: Optional[float]
|
||||
|
||||
@@ -13,6 +13,19 @@ from app.models.user import User, GarminConnectConfig
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _redis_client():
|
||||
import redis as redis_lib
|
||||
return redis_lib.Redis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
def sync_task_key(user_id: int) -> str:
|
||||
return f"garmin_sync_task:{user_id}"
|
||||
|
||||
|
||||
def sync_cancel_key(user_id: int) -> str:
|
||||
return f"garmin_sync_cancel:{user_id}"
|
||||
|
||||
|
||||
class GarminConfigIn(BaseModel):
|
||||
email: str
|
||||
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing.
|
||||
@@ -183,4 +196,47 @@ async def trigger_sync(
|
||||
|
||||
from app.workers.tasks import sync_garmin_connect_user
|
||||
task = sync_garmin_connect_user.delay(current_user.id)
|
||||
|
||||
# Track the active task id and clear any stale cancel flag so the new sync runs.
|
||||
try:
|
||||
r = _redis_client()
|
||||
r.delete(sync_cancel_key(current_user.id))
|
||||
r.set(sync_task_key(current_user.id), task.id, ex=3600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
|
||||
|
||||
@router.post("/cancel")
|
||||
async def cancel_sync(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Request cancellation of the user's in-progress Garmin sync. The running task
|
||||
checks this flag between items and aborts cooperatively."""
|
||||
from app.workers.tasks import celery_app
|
||||
|
||||
try:
|
||||
r = _redis_client()
|
||||
r.set(sync_cancel_key(current_user.id), "1", ex=3600)
|
||||
task_id = r.get(sync_task_key(current_user.id))
|
||||
if task_id:
|
||||
tid = task_id.decode() if isinstance(task_id, (bytes, bytearray)) else task_id
|
||||
# terminate=False: don't kill a running worker mid-transaction; the
|
||||
# cooperative flag handles an already-running task, and this revoke
|
||||
# prevents a still-queued one from starting.
|
||||
celery_app.control.revoke(tid, terminate=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reflect intent immediately so the UI updates before the worker writes "Cancelled".
|
||||
result = await db.execute(
|
||||
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
|
||||
)
|
||||
cfg = result.scalar_one_or_none()
|
||||
if cfg:
|
||||
cfg.last_sync_status = "Cancelling…"
|
||||
await db.commit()
|
||||
|
||||
return {"status": "cancelling"}
|
||||
|
||||
@@ -115,14 +115,21 @@ async def upload_garmin_export(
|
||||
extract_dir = dest_dir / f"garmin_{dest.stem}"
|
||||
|
||||
task_ids = []
|
||||
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 = []
|
||||
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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,9 +722,13 @@ 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()
|
||||
|
||||
try:
|
||||
if sync_acts:
|
||||
_set_status("Syncing activities...")
|
||||
try:
|
||||
@@ -709,6 +737,8 @@ def sync_garmin_connect_user(user_id: int):
|
||||
lookback_days=lookback,
|
||||
status_callback=_set_status,
|
||||
)
|
||||
except SyncCancelled:
|
||||
raise
|
||||
except Exception as exc:
|
||||
errors.append(f"activities: {exc}")
|
||||
|
||||
@@ -720,9 +750,23 @@ def sync_garmin_connect_user(user_id: int):
|
||||
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:
|
||||
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}
|
||||
|
||||
@@ -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: '<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) {
|
||||
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
|
||||
|
||||
@@ -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 (
|
||||
<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 => {
|
||||
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
|
||||
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="distance_m"
|
||||
dataKey="x"
|
||||
type="number"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={v => `${(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
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
|
||||
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} useTimeAxis={useTimeAxis} />}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{metric.key === 'cadence' && sportType === 'running' ? (
|
||||
@@ -171,7 +191,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <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>
|
||||
|
||||
const top = data.leaderboard.slice(0, 10)
|
||||
const fastest = top[0].duration_s
|
||||
return (
|
||||
<div className="space-y-0.5 py-1">
|
||||
{data.leaderboard.map((e, i) => (
|
||||
<div key={e.activity_id} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-5 text-right">{MEDALS[e.rank] || i + 1}</span>
|
||||
<span className="font-mono text-gray-200 w-14 text-right">{formatDuration(e.duration_s)}</span>
|
||||
<a href={`/activities/${e.activity_id}`} className="text-gray-400 hover:text-blue-400 truncate flex-1">{e.activity_name}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<table className="w-full text-sm mt-1 mb-2">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-800">
|
||||
<th className="text-left pb-2 font-medium">#</th>
|
||||
<th className="text-left pb-2 font-medium">Activity</th>
|
||||
<th className="text-right pb-2 font-medium">Time</th>
|
||||
<th className="text-right pb-2 font-medium">Δ</th>
|
||||
</tr>
|
||||
</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 [open, setOpen] = useState(null)
|
||||
|
||||
@@ -36,43 +80,66 @@ export default function SegmentsPanel({ segments }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 text-xs text-gray-600 uppercase tracking-wide">
|
||||
<span className="flex-1">Segment</span>
|
||||
<span className="w-14 text-right">This run</span>
|
||||
<span className="w-14 text-right">Best</span>
|
||||
<span className="w-10 text-right">Place</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-800">
|
||||
<th className="text-left pb-2 font-medium">Segment</th>
|
||||
<th className="text-right pb-2 font-medium">This run</th>
|
||||
<th className="text-right pb-2 font-medium">Best</th>
|
||||
<th className="text-right pb-2 font-medium">Place</th>
|
||||
<th className="pb-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<div key={seg.segment_id} className="border-b border-gray-800/40">
|
||||
<div className="flex items-center gap-3 py-1.5 text-sm">
|
||||
<button onClick={() => setOpen(open === seg.segment_id ? null : seg.segment_id)}
|
||||
className="flex-1 text-left text-gray-300 text-xs truncate hover:text-white">
|
||||
<Fragment key={seg.segment_id}>
|
||||
<tr
|
||||
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
|
||||
>
|
||||
<td className="py-2">
|
||||
<button
|
||||
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
|
||||
className="text-left text-gray-300 hover:text-white"
|
||||
>
|
||||
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
|
||||
{seg.name}
|
||||
<span className="text-gray-600 ml-2">{formatDistance(seg.distance_m)}</span>
|
||||
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m)}</span>
|
||||
</button>
|
||||
<span className={`font-mono text-xs w-14 text-right ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
||||
</td>
|
||||
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
||||
{formatDuration(seg.duration_s)}
|
||||
</span>
|
||||
<span className="font-mono text-xs w-14 text-right text-gray-500">
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono text-gray-500">
|
||||
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
||||
</span>
|
||||
<span className="w-10 text-right text-xs">
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
{isPodium
|
||||
? <span title="New podium time on this activity">{MEDALS[seg.rank]}</span>
|
||||
? <span title="Podium time on this activity" className="text-yellow-400">{MEDALS[seg.rank]}</span>
|
||||
: delta != null
|
||||
? <span className="text-red-400 font-mono">+{formatDuration(delta)}</span>
|
||||
? <span className="text-gray-500">+{formatDuration(delta)}</span>
|
||||
: <span className="text-gray-700">--</span>}
|
||||
</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>
|
||||
</div>
|
||||
{open === seg.segment_id && <Leaderboard segmentId={seg.segment_id} />}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -143,7 +143,11 @@ export default function ActivityDetailPage() {
|
||||
{/* Stats — all on one row */}
|
||||
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3">
|
||||
<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="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
||||
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
||||
@@ -162,7 +166,8 @@ export default function ActivityDetailPage() {
|
||||
</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">
|
||||
{/* Map toolbar */}
|
||||
<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 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 */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
@@ -300,25 +310,26 @@ export default function ActivityDetailPage() {
|
||||
)}
|
||||
</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)) && (
|
||||
<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 && (
|
||||
<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>
|
||||
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
<RouteLeaderboard data={routeBoard} />
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
<SegmentsPanel segments={actSegments} />
|
||||
<SegmentsPanel segments={actSegments} activityId={Number(id)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
</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="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
|
||||
<StatCard label="Resting HR" value={formatHeartRate(health.resting_hr)} accent="red" />
|
||||
|
||||
@@ -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() {
|
||||
<p className="text-xs text-gray-600">Click any point to load that day above</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{RANGES.map(({ label, days }) => (
|
||||
<button key={label} onClick={() => setRangeDays(days)}
|
||||
{RANGES.map(({ label, days }, i) => {
|
||||
const disabled = i > maxEnabledRangeIdx
|
||||
return (
|
||||
<button key={label}
|
||||
onClick={() => !disabled && setRangeDays(days)}
|
||||
disabled={disabled}
|
||||
title={disabled ? 'Not enough history for this range' : undefined}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
rangeDays === days
|
||||
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>
|
||||
|
||||
|
||||
@@ -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,12 +423,20 @@ export default function ProfilePage() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 flex-1 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
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>
|
||||
<p className="text-xs text-blue-400">
|
||||
{status || 'Starting sync…'}
|
||||
</p>
|
||||
|
||||
@@ -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 <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 === 'skipped') return <span className="ml-2 text-amber-400">⚠ Skipped</span>
|
||||
if (status === 'failed') return <span className="ml-2 text-red-400">✗ Failed</span>
|
||||
return <span className="ml-2 text-green-400">✓ Queued</span>
|
||||
}
|
||||
@@ -107,13 +119,20 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
{tasks.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{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">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-300 truncate flex-1">{task.file}</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>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user