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.
|
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/
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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…'}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user