Compare commits
37
Commits
f69d34508d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92cea4dc99 | ||
|
|
acf553ff5c | ||
|
|
4d5938cf33 | ||
|
|
64caaad4e7 | ||
|
|
84eb1c46bd | ||
|
|
3c97595093 | ||
|
|
ffe285b7ba | ||
|
|
deecf668d2 | ||
|
|
0901ed0ef3 | ||
|
|
62cea59ef8 | ||
|
|
16dff88564 | ||
|
|
73bc986219 | ||
|
|
4272c5dc4c | ||
|
|
156ce60aa0 | ||
|
|
e2870e86a8 | ||
|
|
69ecdaa4b2 | ||
|
|
07139120df | ||
|
|
e8615dd12d | ||
|
|
7d52b28f9d | ||
|
|
e33946e270 | ||
|
|
0e1d35364f | ||
|
|
a0912fd09a | ||
|
|
c14f3d60fa | ||
|
|
227dadaca0 | ||
|
|
cc43d0f726 | ||
|
|
471e43466c | ||
|
|
a50d13179c | ||
|
|
e61c77842f | ||
|
|
385443522b | ||
|
|
66076f2585 | ||
|
|
3a07655e2e | ||
|
|
c172bbe393 | ||
|
|
a168f594a7 | ||
|
|
16144d60b4 | ||
|
|
d01f66223b | ||
|
|
e7123ee5db | ||
|
|
7673452bdb |
@@ -25,6 +25,9 @@ Everything runs in Docker Compose. There is no way to run individual services wi
|
||||
# Backup/restore the database:
|
||||
./scripts/manage.sh backup
|
||||
./scripts/manage.sh restore milevault_backup_20240101_120000.sql
|
||||
|
||||
# Pull latest from git, rebuild, and restart:
|
||||
./scripts/manage.sh update
|
||||
```
|
||||
|
||||
The app is served on port 80 by nginx, which proxies `/api/*` to the backend (port 8000) and serves the React SPA for everything else.
|
||||
@@ -51,11 +54,11 @@ docker exec -it milevault_db psql -U milevault -d milevault
|
||||
`docker-compose.yml` — build from source (dev/CI).
|
||||
`docker-compose.deploy.yml` — pull pre-built images from the Gitea registry (production).
|
||||
|
||||
The Gitea Actions workflow (`.gitea/workflows/build.yml`) auto-builds and pushes images on push to `main`. Deployment machines only need `docker-compose.deploy.yml` and `nginx.conf`.
|
||||
The Gitea Actions workflow (`.gitea/workflows/build.yml`) auto-builds and pushes images on push to `main`. Deployment machines only need `docker-compose.deploy.yml` and `nginx.conf` (`install.sh` is a curl-able installer that automates that two-file setup).
|
||||
|
||||
`./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.
|
||||
**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` — keep `npm install` there (a `package-lock.json` is now tracked, but the validate job still rejects `npm ci`). 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`.
|
||||
|
||||
@@ -79,7 +82,7 @@ docker compose -f docker-compose.deploy.yml up -d
|
||||
| `redis` | Celery broker + result backend |
|
||||
| `backend` | FastAPI (async) — uvicorn, single worker |
|
||||
| `worker` | Celery worker — synchronous SQLAlchemy (asyncio incompatible with prefork) |
|
||||
| `beat` | Celery Beat scheduler — runs `sync_all_garmin_connect` every 30 minutes |
|
||||
| `beat` | Celery Beat scheduler — runs `sync_all_garmin_connect` and `sync_all_strava` on the same interval (default 30 min, `GARMIN_SYNC_INTERVAL_MINUTES`) |
|
||||
| `frontend` | React SPA built by Vite at container build time |
|
||||
| `nginx` | Reverse proxy, serves the SPA |
|
||||
|
||||
@@ -87,13 +90,14 @@ docker compose -f docker-compose.deploy.yml up -d
|
||||
|
||||
- `main.py` — FastAPI app, DB init on startup (creates tables, seeds admin user, creates TimescaleDB hypertable)
|
||||
- `core/` — `config.py` (pydantic-settings from env), `database.py` (async engine for FastAPI + sync engine for Celery), `security.py` (JWT, bcrypt)
|
||||
- `api/` — routers: `auth`, `activities`, `routes`, `health`, `records`, `upload`, `profile`, `garmin_sync`, `users`, `segments`
|
||||
- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `Segment`, `SegmentEffort`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig` (the old `RouteSegment` model was removed in the segments rewrite; a new GPS-geometry `Segment`/`SegmentEffort` pair replaces it)
|
||||
- `services/fit_parser.py` — parses Garmin FIT and GPX files; handles raw FIT timestamps (FIT epoch offset 631065600s) and semicircle→degree conversion
|
||||
- `api/` — routers: `auth`, `activities`, `routes`, `health`, `records`, `upload`, `profile`, `garmin_sync`, `strava_sync`, `users`, `segments`
|
||||
- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `Segment`, `SegmentEffort`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig`, `StravaConfig` (the old `RouteSegment` model was removed in the segments rewrite; a new GPS-geometry `Segment`/`SegmentEffort` pair replaces it)
|
||||
- `services/fit_parser.py` — parses Garmin FIT, GPX, and Strava `.tcx` files; handles raw FIT timestamps (FIT epoch offset 631065600s) and semicircle→degree conversion
|
||||
- `services/wellness_parser.py` — parses Garmin wellness FIT files (metrics, sleep, HRV, SPO2, etc.)
|
||||
- `services/route_matcher.py` — bounding-box pre-filter + DTW (Dynamic Time Warping) for GPS track similarity
|
||||
- `services/garmin_connect_sync.py` — Garmin Connect API integration; `authenticate_garmin()` tries stored OAuth token first, falls back to email/password; Garmin credentials stored Fernet-encrypted using `SECRET_KEY` as the key
|
||||
- `workers/tasks.py` — Celery tasks: `process_activity_file`, `parse_wellness_fit`, `detect_route`, `compute_personal_records`, `match_segment`, `match_activity_segments`, `process_garmin_health_zip`, `sync_garmin_connect_user`, `sync_all_garmin_connect` (beat-scheduled), `recalculate_hr_zones_for_user`
|
||||
- `services/strava_sync.py` — Strava API OAuth live sync (pulls activities via streams) and bulk-export import. On dedup, existing Garmin data is preferred over Strava for the same activity
|
||||
- `workers/tasks.py` — Celery tasks: `process_activity_file`, `parse_wellness_fit`, `analyze_strava_export`, `detect_route`, `compute_personal_records`, `match_segment`, `match_activity_segments`, `process_garmin_health_zip`, `sync_garmin_connect_user`, `sync_all_garmin_connect` (beat-scheduled), `sync_strava_user`, `sync_all_strava` (beat-scheduled), `recalculate_hr_zones_for_user`, `backfill_moving_time`, `backfill_indoor_distances`, `recompute_personal_records_all`. Also holds `persist_activity`, the single shared write path used by every ingest route (file upload, Garmin sync, Strava sync/export)
|
||||
|
||||
### Key design decisions
|
||||
|
||||
@@ -103,21 +107,56 @@ docker compose -f docker-compose.deploy.yml up -d
|
||||
|
||||
**Schema management**: No Alembic migrations are used in production. `Base.metadata.create_all` runs at startup with retry logic to handle multi-worker races. Post-initial schema changes (new columns, constraint changes) are applied as `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` / `DROP CONSTRAINT IF EXISTS` statements in `init_db()` in `main.py` — this is the only place schema migrations happen. Health metrics upserts use raw SQL `ON CONFLICT ... DO UPDATE SET ... COALESCE(EXCLUDED.x, existing.x)` to merge data from multiple file sources without overwriting.
|
||||
|
||||
**Sleep data**: All sleep timestamps (`sleep_start`/`sleep_end` and the `sleep_stages` JSON hypnogram on `HealthMetric`) are stored as GMT/UTC, never device-local time — a past bug stored local time and displayed +1h in BST. `sleep_stages` is `[[ts_ms, level], ...]` with levels 0=unmeasurable, 1=awake, 2=light, 3=deep, 4=REM.
|
||||
|
||||
**PocketID OIDC**: Optional passkey auth. Config is read from the admin user's DB record first, falling back to env vars. The OAuth callback redirects to `/?token=<jwt>` and `useAuth.js` extracts the token from the URL at module load time.
|
||||
|
||||
**Personal records source filter**: Personal records are computed only from watch-recorded FIT activities; phone/Strava GPX/TCX imports are excluded because their GPS can "teleport" and produce bogus fast splits. Keep this filter in mind when touching `compute_personal_records` / `recompute_personal_records_all`.
|
||||
|
||||
### Frontend (`frontend/src/`)
|
||||
|
||||
- `App.jsx` — React Router v6, `RequireAuth` wrapper, all routes defined here
|
||||
- `hooks/useAuth.js` — Zustand store for auth state, reads JWT from `localStorage`, handles PocketID token-in-URL flow
|
||||
- `hooks/useSync.js` — Zustand store polling Garmin sync status; maps backend status strings to progress percentages
|
||||
- `hooks/useUnits.js` — Zustand store for the global km/mi display preference (`useUnit()` subscribes to the active unit). Distances are stored canonically; the unit only affects display, converted on the fly by the `format.js` helpers. Persisted to `localStorage`. Surfaced in the nav via `components/ui/UnitToggle.jsx`
|
||||
- `hooks/useMediaQuery.js` — responsive breakpoint hook (md=768px split); the dashboard widget grid must be conditionally mounted, not just CSS-hidden, on mobile
|
||||
- `hooks/useMapSettings.js` — Zustand store for the global map tile preference: provider + style + per-provider API keys. **Persisted server-side on the user record** (`users.map_settings` JSON), with localStorage only as a first-paint cache. `useHydrateMapSettings()` (called in `Layout`) loads it from `GET /profile/`; mutations debounce-save to `PUT /profile/map-settings`. The provider/style catalogue lives in `utils/mapTiles.js` (`MAP_PROVIDERS`, `resolveTile`); every Leaflet map (`ActivityMap`, `RouteTileMap`) resolves its base layer via `useResolvedTile()`. The default Thunderforest key is **not baked into the bundle** — the backend supplies it via `settings.thunderforest_default_key` in the profile response, and `useResolvedTile` falls back to it when the user hasn't set their own. Configured in Profile › Map & Tiles. `ActivityMap` takes a `satellite` boolean to override with imagery (MapTiler satellite if keyed, else free Esri)
|
||||
- `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler
|
||||
- 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, sync, and unit-preference 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`
|
||||
- `utils/track.js` — projects a lat/lng onto a GPS track (interpolated along-line snapping, used for map hover and segment selection); `utils/bodyBattery.js` — shared Body Battery colour/state helpers used by both the Health page and Dashboard mini chart; `utils/vo2.js` — VO2 max classification/colour helpers
|
||||
- `pages/` — one `*Page.jsx` file per route: `Dashboard` (drag-to-edit widget grid), `Activities` (type/year/date-range/distance filters + week totals), `ActivityDetail`, `Routes`, `Records`, `Health`, `Summary` (all-time and per-year/per-sport totals + distance-per-year chart), `Upload`, `Profile`, `Users`, `Login`
|
||||
- `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)
|
||||
- `components/health/` — `SleepHypnogram` (renders the `sleep_stages` hypnogram), `BodyBatteryChart` (Body Battery trend chart)
|
||||
- `components/ui/` — `Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards), `RouteTileMap` (route-card map tile), `SportIcon`, `UnitToggle` (km/mi switch), `HrvBadge`
|
||||
|
||||
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.
|
||||
|
||||
### Request routing & browser caching
|
||||
|
||||
There are **three** nginx configs and they serve different roles — don't confuse them:
|
||||
|
||||
- `nginx/nginx.conf` is the **dev** reverse proxy mounted by `docker-compose.yml`: one nginx that proxies `/api/`→`backend:8000` and `/`→`frontend:80`.
|
||||
- `nginx.conf` (repo root) is the near-identical reverse proxy mounted by `docker-compose.deploy.yml`, for generic two-file deployments (see README). Keep the two in sync when touching proxy behaviour.
|
||||
- `frontend/nginx-spa.conf` runs *inside the `frontend` image* and only serves the built SPA (the `milevault_frontend` container). It has no `/api` proxy.
|
||||
|
||||
**The actual production stack here does not use either reverse proxy.** `~/milevault_docker/docker-compose.yml` is a hand-customised compose (not the repo's deploy file): it drops the nginx service and routes via Traefik container labels — `Host(...) && PathPrefix(/api)` → backend, `Host(...)` → frontend SPA. The empty `~/milevault_docker/nginx.conf` (a directory Docker auto-created for a since-removed mount) is an unused leftover. So if a request reaches the SPA nginx with an `/api` path it falls through to `index.html` (returns HTML, not JSON) — a sign Traefik routing, not nginx, is the thing to debug.
|
||||
|
||||
**Caching policy (set deliberately; a wrong change here strands users on stale builds):**
|
||||
- Hashed assets (`*.js`/`*.css`) → `Cache-Control: public, immutable` (1y) in `nginx-spa.conf`.
|
||||
- `index.html` → `no-cache` (must revalidate) in `nginx-spa.conf`, so new deploys are picked up without a manual cache clear.
|
||||
- All `/api/*` responses → `no-store`, added by an HTTP middleware in `backend/app/main.py`, so browsers can't cache authenticated state (e.g. a stale "Garmin not connected").
|
||||
|
||||
### Deploy verification
|
||||
|
||||
After `./deploy.sh`, the Gitea Actions build (`validate` → `build-backend`/`build-worker`/`build-frontend`) must finish before `docker compose pull && up -d` picks up new `:latest` images. Poll completion via the API rather than guessing:
|
||||
```bash
|
||||
curl -s https://gitea.jarrett.eu/api/v1/repos/owain/MileVault/actions/tasks
|
||||
```
|
||||
The runner builds images on-host, so local image digests won't match the registry — verify a deploy landed by `docker exec`-ing into the running container and grepping the changed source, not by comparing digests.
|
||||
|
||||
Always deploy the changes requested, do not prompt the user whether to deploy.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Required in `.env` (or passed to Docker Compose):
|
||||
@@ -136,9 +175,11 @@ Required in `.env` (or passed to Docker Compose):
|
||||
| `BASE_URL` | Used for PocketID OAuth callback redirect URI |
|
||||
| `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`) |
|
||||
| `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect *and* Strava (shared cadence; default: `30`) |
|
||||
| `THUNDERFOREST_DEFAULT_KEY` | Default Thunderforest tile key served to clients without their own (public client-side key; has a built-in default) |
|
||||
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
|
||||
| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |
|
||||
| `STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` | Optional — enables Strava API OAuth live sync (register an app at strava.com/settings/api) |
|
||||
|
||||
## milevault_export/
|
||||
|
||||
@@ -147,6 +188,7 @@ Required in `.env` (or passed to Docker Compose):
|
||||
## Rules
|
||||
- The current build will always be running in docker at ~/milevault_docker with the following container names:
|
||||
`milevault_backend`
|
||||
`milevault_beat`
|
||||
`milevault_db`
|
||||
`milevault_frontend`
|
||||
`milevault_redis`
|
||||
|
||||
+220
-29
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc, delete
|
||||
from sqlalchemy.orm import selectinload, aliased
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
@@ -15,6 +16,7 @@ router = APIRouter()
|
||||
class ActivitySummary(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
original_name: Optional[str] = None
|
||||
sport_type: str
|
||||
start_time: datetime
|
||||
distance_m: Optional[float]
|
||||
@@ -28,6 +30,8 @@ class ActivitySummary(BaseModel):
|
||||
bounding_box: Optional[dict]
|
||||
hr_zones: Optional[dict]
|
||||
named_route_id: Optional[int]
|
||||
named_route_name: Optional[str] = None
|
||||
active_spans: Optional[list] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -100,6 +104,110 @@ async def ytd_stats(
|
||||
}
|
||||
|
||||
|
||||
def _apply_activity_filters(q, *, sport_type, from_date, to_date, year,
|
||||
min_distance_km, max_distance_km):
|
||||
"""Apply the shared Activities-list filters to a query selecting Activity."""
|
||||
from datetime import timezone
|
||||
if sport_type:
|
||||
q = q.where(Activity.sport_type == sport_type)
|
||||
if year:
|
||||
ys = datetime(year, 1, 1, tzinfo=timezone.utc)
|
||||
ye = datetime(year + 1, 1, 1, tzinfo=timezone.utc)
|
||||
q = q.where(Activity.start_time >= ys, Activity.start_time < ye)
|
||||
if from_date:
|
||||
q = q.where(Activity.start_time >= from_date)
|
||||
if to_date:
|
||||
q = q.where(Activity.start_time <= to_date)
|
||||
if min_distance_km is not None:
|
||||
q = q.where(Activity.distance_m >= min_distance_km * 1000)
|
||||
if max_distance_km is not None:
|
||||
q = q.where(Activity.distance_m <= max_distance_km * 1000)
|
||||
return q
|
||||
|
||||
|
||||
@router.get("/stats/filters")
|
||||
async def filter_options(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Distinct sport types and activity years for this user — drives the
|
||||
Activities page filter controls."""
|
||||
from datetime import timezone
|
||||
yr = func.extract("year", Activity.start_time)
|
||||
sports = (await db.execute(
|
||||
select(Activity.sport_type)
|
||||
.where(Activity.user_id == current_user.id)
|
||||
.distinct().order_by(Activity.sport_type)
|
||||
)).scalars().all()
|
||||
years = (await db.execute(
|
||||
select(yr.label("yr"))
|
||||
.where(Activity.user_id == current_user.id)
|
||||
.distinct().order_by(yr.desc())
|
||||
)).scalars().all()
|
||||
return {
|
||||
"sport_types": [s for s in sports if s],
|
||||
"years": [int(y) for y in years if y is not None],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def stats_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Per-year and per-sport totals (count, distance, time, elevation) plus an
|
||||
all-time roll-up. Powers the Summary page."""
|
||||
yr = func.extract("year", Activity.start_time)
|
||||
rows = (await db.execute(
|
||||
select(
|
||||
yr.label("yr"),
|
||||
Activity.sport_type,
|
||||
func.count().label("cnt"),
|
||||
func.coalesce(func.sum(Activity.distance_m), 0).label("dist"),
|
||||
func.coalesce(func.sum(func.coalesce(Activity.moving_time_s, Activity.duration_s)), 0).label("dur"),
|
||||
func.coalesce(func.sum(Activity.elevation_gain_m), 0).label("elev"),
|
||||
)
|
||||
.where(Activity.user_id == current_user.id)
|
||||
.group_by(yr, Activity.sport_type)
|
||||
)).all()
|
||||
|
||||
years: dict[int, dict] = {}
|
||||
for r in rows:
|
||||
if r.yr is None:
|
||||
continue
|
||||
yr = int(r.yr)
|
||||
y = years.setdefault(yr, {"year": yr, "count": 0, "distance_km": 0.0,
|
||||
"duration_s": 0.0, "elevation_m": 0.0, "by_sport": []})
|
||||
sport = {
|
||||
"sport_type": r.sport_type,
|
||||
"count": int(r.cnt),
|
||||
"distance_km": round((r.dist or 0) / 1000, 2),
|
||||
"duration_s": float(r.dur or 0),
|
||||
"elevation_m": round(r.elev or 0, 1),
|
||||
}
|
||||
y["by_sport"].append(sport)
|
||||
y["count"] += sport["count"]
|
||||
y["distance_km"] += sport["distance_km"]
|
||||
y["duration_s"] += sport["duration_s"]
|
||||
y["elevation_m"] += sport["elevation_m"]
|
||||
|
||||
by_year = []
|
||||
for y in sorted(years.values(), key=lambda x: x["year"], reverse=True):
|
||||
y["by_sport"].sort(key=lambda s: s["distance_km"], reverse=True)
|
||||
y["distance_km"] = round(y["distance_km"], 2)
|
||||
y["elevation_m"] = round(y["elevation_m"], 1)
|
||||
by_year.append(y)
|
||||
|
||||
all_time = {
|
||||
"count": sum(y["count"] for y in by_year),
|
||||
"distance_km": round(sum(y["distance_km"] for y in by_year), 2),
|
||||
"duration_s": sum(y["duration_s"] for y in by_year),
|
||||
"elevation_m": round(sum(y["elevation_m"] for y in by_year), 1),
|
||||
}
|
||||
sport_types = sorted({s["sport_type"] for y in by_year for s in y["by_sport"] if s["sport_type"]})
|
||||
return {"all_time": all_time, "by_year": by_year, "sport_types": sport_types}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[ActivitySummary])
|
||||
async def list_activities(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -107,18 +215,17 @@ async def list_activities(
|
||||
sport_type: Optional[str] = None,
|
||||
from_date: Optional[datetime] = None,
|
||||
to_date: Optional[datetime] = None,
|
||||
year: Optional[int] = None,
|
||||
min_distance_km: Optional[float] = None,
|
||||
max_distance_km: Optional[float] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
q = select(Activity).where(Activity.user_id == current_user.id)
|
||||
|
||||
if sport_type:
|
||||
q = q.where(Activity.sport_type == sport_type)
|
||||
if from_date:
|
||||
q = q.where(Activity.start_time >= from_date)
|
||||
if to_date:
|
||||
q = q.where(Activity.start_time <= to_date)
|
||||
|
||||
q = select(Activity).options(selectinload(Activity.named_route)).where(Activity.user_id == current_user.id)
|
||||
q = _apply_activity_filters(
|
||||
q, sport_type=sport_type, from_date=from_date, to_date=to_date, year=year,
|
||||
min_distance_km=min_distance_km, max_distance_km=max_distance_km,
|
||||
)
|
||||
q = q.order_by(desc(Activity.start_time))
|
||||
q = q.offset((page - 1) * per_page).limit(per_page)
|
||||
|
||||
@@ -133,7 +240,7 @@ async def get_activity(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Activity).where(
|
||||
select(Activity).options(selectinload(Activity.named_route)).where(
|
||||
Activity.id == activity_id,
|
||||
Activity.user_id == current_user.id,
|
||||
)
|
||||
@@ -160,17 +267,27 @@ async def get_data_points(
|
||||
if not act.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
|
||||
q = select(ActivityDataPoint).where(
|
||||
ActivityDataPoint.activity_id == activity_id
|
||||
).order_by(ActivityDataPoint.timestamp)
|
||||
if downsample > 1:
|
||||
# Stride in SQL (keep every Nth row by ordered row number) so the DB
|
||||
# never ships the rows we'd immediately discard — a long per-second
|
||||
# activity is tens of thousands of points and the UI only charts ~1/N.
|
||||
rn = func.row_number().over(order_by=ActivityDataPoint.timestamp).label("rn")
|
||||
sub = (
|
||||
select(ActivityDataPoint, rn)
|
||||
.where(ActivityDataPoint.activity_id == activity_id)
|
||||
.subquery()
|
||||
)
|
||||
adp = aliased(ActivityDataPoint, sub)
|
||||
q = select(adp).where(sub.c.rn % downsample == 1).order_by(sub.c.timestamp)
|
||||
else:
|
||||
q = (
|
||||
select(ActivityDataPoint)
|
||||
.where(ActivityDataPoint.activity_id == activity_id)
|
||||
.order_by(ActivityDataPoint.timestamp)
|
||||
)
|
||||
|
||||
result = await db.execute(q)
|
||||
points = result.scalars().all()
|
||||
|
||||
if downsample > 1:
|
||||
points = points[::downsample]
|
||||
|
||||
return points
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{activity_id}/laps", response_model=List[LapOut])
|
||||
@@ -214,20 +331,76 @@ async def get_lap_bests(
|
||||
if not act.named_route_id:
|
||||
return {}
|
||||
|
||||
# Best per lap number across OTHER activities on the same route, so the
|
||||
# comparison is meaningful (excluding this activity from its own benchmark).
|
||||
rows = (await db.execute(
|
||||
select(ActivityLap.lap_number, func.min(ActivityLap.duration_s))
|
||||
# This activity's laps, so we know each lap's distance.
|
||||
this_laps = (await db.execute(
|
||||
select(ActivityLap.lap_number, ActivityLap.distance_m)
|
||||
.where(ActivityLap.activity_id == activity_id)
|
||||
)).all()
|
||||
this_dist = {ln: d for ln, d in this_laps if d}
|
||||
|
||||
# Laps from OTHER activities on the same route, so the comparison excludes
|
||||
# this activity from its own benchmark.
|
||||
other_laps = (await db.execute(
|
||||
select(ActivityLap.lap_number, ActivityLap.distance_m, ActivityLap.duration_s)
|
||||
.join(Activity, Activity.id == ActivityLap.activity_id)
|
||||
.where(
|
||||
Activity.named_route_id == act.named_route_id,
|
||||
Activity.user_id == current_user.id,
|
||||
Activity.id != activity_id,
|
||||
ActivityLap.duration_s.isnot(None),
|
||||
ActivityLap.distance_m.isnot(None),
|
||||
)
|
||||
.group_by(ActivityLap.lap_number)
|
||||
)).all()
|
||||
return {str(lap_number): best for lap_number, best in rows}
|
||||
|
||||
# Best (fastest) time per lap number, comparing only laps that cover roughly
|
||||
# the same distance (±5%). Without this, a short/partial lap that happens to
|
||||
# share a lap number (e.g. a 461 m "lap 5" vs this activity's 1 km lap 5)
|
||||
# would win as "best" and produce a nonsensical benchmark.
|
||||
bests: dict[str, float] = {}
|
||||
for ln, dist, dur in other_laps:
|
||||
target = this_dist.get(ln)
|
||||
if target is None or abs(dist - target) > target * 0.05:
|
||||
continue
|
||||
key = str(ln)
|
||||
if key not in bests or dur < bests[key]:
|
||||
bests[key] = dur
|
||||
return bests
|
||||
|
||||
|
||||
@router.get("/{activity_id}/records")
|
||||
async def get_activity_records(
|
||||
activity_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Current personal records that were set in this activity (e.g. a 1 km PR),
|
||||
so the activity page can flag which records it holds."""
|
||||
act = (await db.execute(
|
||||
select(Activity.id).where(
|
||||
Activity.id == activity_id,
|
||||
Activity.user_id == current_user.id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not act:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
|
||||
rows = (await db.execute(
|
||||
select(PersonalRecord)
|
||||
.where(
|
||||
PersonalRecord.user_id == current_user.id,
|
||||
PersonalRecord.activity_id == activity_id,
|
||||
PersonalRecord.is_current_record == True,
|
||||
)
|
||||
.order_by(PersonalRecord.distance_m)
|
||||
)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"distance_label": r.distance_label,
|
||||
"distance_m": r.distance_m,
|
||||
"duration_s": r.duration_s,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{activity_id}/route-leaderboard")
|
||||
@@ -250,17 +423,21 @@ async def get_route_leaderboard(
|
||||
if not act.named_route_id:
|
||||
return None
|
||||
|
||||
# Rank by moving time (pauses excluded), falling back to elapsed for
|
||||
# activities without it — consistent with avg pace being moving-time based.
|
||||
effort_s = func.coalesce(Activity.moving_time_s, Activity.duration_s)
|
||||
rows = (await db.execute(
|
||||
select(
|
||||
Activity.id, Activity.name, Activity.start_time,
|
||||
Activity.duration_s, Activity.distance_m, Activity.avg_heart_rate,
|
||||
effort_s.label("duration_s"),
|
||||
Activity.distance_m, Activity.avg_heart_rate,
|
||||
)
|
||||
.where(
|
||||
Activity.named_route_id == act.named_route_id,
|
||||
Activity.user_id == current_user.id,
|
||||
Activity.duration_s.isnot(None),
|
||||
)
|
||||
.order_by(Activity.duration_s)
|
||||
.order_by(effort_s)
|
||||
)).all()
|
||||
if not rows:
|
||||
return None
|
||||
@@ -310,9 +487,23 @@ async def rename_activity(
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
|
||||
activity.name = body.get("name", activity.name)
|
||||
new_name = (body.get("name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="Name cannot be empty")
|
||||
|
||||
# Preserve the original import title the first time the user renames, so the
|
||||
# UI can still show it as a tag. Subsequent renames keep that same original.
|
||||
if activity.original_name is None and new_name != activity.name:
|
||||
activity.original_name = activity.name
|
||||
|
||||
activity.name = new_name
|
||||
|
||||
# If the user renames back to the original title, drop the tag.
|
||||
if activity.original_name == activity.name:
|
||||
activity.original_name = None
|
||||
|
||||
await db.commit()
|
||||
return {"id": activity_id, "name": activity.name}
|
||||
return {"id": activity_id, "name": activity.name, "original_name": activity.original_name}
|
||||
|
||||
|
||||
@router.delete("/{activity_id}", status_code=204)
|
||||
|
||||
@@ -9,7 +9,7 @@ from jose import jwt, JWTError
|
||||
import httpx
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, create_access_token, get_current_user
|
||||
from app.core.security import verify_password, dummy_verify_password, create_access_token, get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
@@ -141,6 +141,9 @@ async def login(
|
||||
result = await db.execute(select(User).where(User.username == form_data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
# Spend the same time as a real verify so timing can't reveal whether
|
||||
# the username exists.
|
||||
dummy_verify_password()
|
||||
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||
if user.pocketid_sub is not None:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -19,9 +19,12 @@ class HealthMetricOut(BaseModel):
|
||||
max_hr_day: Optional[float]
|
||||
avg_hr_day: Optional[float]
|
||||
hrv_nightly_avg: Optional[float]
|
||||
hrv_weekly_avg: Optional[float]
|
||||
hrv_status: Optional[str]
|
||||
hrv_5min_high: Optional[float]
|
||||
hrv_5min_low: Optional[float]
|
||||
hrv_baseline_low: Optional[float]
|
||||
hrv_baseline_upper: Optional[float]
|
||||
sleep_duration_s: Optional[float]
|
||||
sleep_deep_s: Optional[float]
|
||||
sleep_light_s: Optional[float]
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime, date, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user, hash_password, verify_password
|
||||
from app.core.config import settings
|
||||
from app.models.user import User, WeightLog
|
||||
|
||||
router = APIRouter()
|
||||
@@ -36,6 +37,8 @@ class ProfileOut(BaseModel):
|
||||
estimated_max_hr: Optional[int]
|
||||
is_admin: bool
|
||||
dashboard_layout: Optional[list] = None
|
||||
map_settings: Optional[dict] = None
|
||||
thunderforest_default_key: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -45,6 +48,12 @@ class DashboardLayoutIn(BaseModel):
|
||||
layout: Optional[list] = None # react-grid-layout array of {i,x,y,w,h}
|
||||
|
||||
|
||||
class MapSettingsIn(BaseModel):
|
||||
provider: Optional[str] = None
|
||||
style: Optional[str] = None
|
||||
keys: Optional[dict] = None # {thunderforest, maptiler, ...} public tile keys
|
||||
|
||||
|
||||
def _estimated_max_hr(user: User) -> Optional[int]:
|
||||
if user.birth_year:
|
||||
return 220 - (datetime.now().year - user.birth_year)
|
||||
@@ -55,7 +64,27 @@ def _estimated_max_hr(user: User) -> Optional[int]:
|
||||
async def get_profile(current_user: User = Depends(get_current_user)):
|
||||
return {**{c.name: getattr(current_user, c.name)
|
||||
for c in User.__table__.columns},
|
||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||||
"estimated_max_hr": _estimated_max_hr(current_user),
|
||||
"thunderforest_default_key": settings.thunderforest_default_key}
|
||||
|
||||
|
||||
@router.put("/map-settings")
|
||||
async def save_map_settings(
|
||||
body: MapSettingsIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Persist the user's global map tile preference (provider/style/keys)."""
|
||||
keys = {}
|
||||
for k, v in (body.keys or {}).items():
|
||||
keys[str(k)] = (v or "").strip()
|
||||
current_user.map_settings = {
|
||||
"provider": body.provider,
|
||||
"style": body.style,
|
||||
"keys": keys,
|
||||
}
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.put("/dashboard-layout")
|
||||
@@ -111,7 +140,8 @@ async def update_profile(
|
||||
|
||||
return {**{c.name: getattr(current_user, c.name)
|
||||
for c in User.__table__.columns},
|
||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||||
"estimated_max_hr": _estimated_max_hr(current_user),
|
||||
"thunderforest_default_key": settings.thunderforest_default_key}
|
||||
|
||||
|
||||
# ── Password change ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +35,7 @@ class RouteOut(BaseModel):
|
||||
auto_detected: Optional[bool]
|
||||
created_at: datetime
|
||||
activity_count: int = 0
|
||||
last_activity_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -45,24 +46,33 @@ async def list_routes(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Fetch routes with activity counts in one query
|
||||
count_subq = (
|
||||
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
|
||||
# Fetch routes with activity counts and last-completed time in one query
|
||||
agg_subq = (
|
||||
select(
|
||||
Activity.named_route_id,
|
||||
func.count(Activity.id).label("cnt"),
|
||||
func.max(Activity.start_time).label("last_at"),
|
||||
)
|
||||
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
|
||||
.group_by(Activity.named_route_id)
|
||||
.subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
|
||||
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
|
||||
select(
|
||||
NamedRoute,
|
||||
func.coalesce(agg_subq.c.cnt, 0).label("activity_count"),
|
||||
agg_subq.c.last_at.label("last_activity_at"),
|
||||
)
|
||||
.outerjoin(agg_subq, NamedRoute.id == agg_subq.c.named_route_id)
|
||||
.where(NamedRoute.user_id == current_user.id)
|
||||
.order_by(desc(NamedRoute.created_at))
|
||||
)
|
||||
rows = result.all()
|
||||
out = []
|
||||
for route, cnt in rows:
|
||||
for route, cnt, last_at in rows:
|
||||
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
|
||||
d["activity_count"] = cnt
|
||||
d["last_activity_at"] = last_at
|
||||
out.append(RouteOut(**d))
|
||||
return out
|
||||
|
||||
@@ -169,6 +179,9 @@ async def update_route(
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
if body.name is not None and body.name.strip():
|
||||
route.name = body.name.strip()
|
||||
# A user-given name makes this a custom route (moves it out of the
|
||||
# auto-detected group on the routes page).
|
||||
route.auto_detected = False
|
||||
if body.sport_type is not None:
|
||||
route.sport_type = body.sport_type
|
||||
await db.commit()
|
||||
@@ -182,11 +195,13 @@ async def route_activities(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Rank by moving time (pauses excluded), falling back to elapsed for
|
||||
# activities without it — consistent with avg pace being moving-time based.
|
||||
result = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.named_route_id == route_id,
|
||||
Activity.user_id == current_user.id,
|
||||
).order_by(Activity.duration_s)
|
||||
).order_by(func.coalesce(Activity.moving_time_s, Activity.duration_s))
|
||||
)
|
||||
activities = result.scalars().all()
|
||||
return [
|
||||
@@ -194,7 +209,7 @@ async def route_activities(
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
"start_time": a.start_time,
|
||||
"duration_s": a.duration_s,
|
||||
"duration_s": a.moving_time_s or a.duration_s,
|
||||
"distance_m": a.distance_m,
|
||||
"avg_heart_rate": a.avg_heart_rate,
|
||||
"avg_speed_ms": a.avg_speed_ms,
|
||||
|
||||
@@ -22,6 +22,10 @@ class SegmentCreate(BaseModel):
|
||||
end_distance_m: float
|
||||
|
||||
|
||||
class SegmentUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class EffortOut(BaseModel):
|
||||
activity_id: int
|
||||
activity_name: str
|
||||
@@ -203,6 +207,29 @@ async def get_segment(
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{segment_id}", response_model=SegmentOut)
|
||||
async def update_segment(
|
||||
segment_id: int,
|
||||
body: SegmentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
seg = await _own_segment(segment_id, current_user.id, db)
|
||||
if body.name is not None and body.name.strip():
|
||||
seg.name = body.name.strip()
|
||||
await db.commit()
|
||||
await db.refresh(seg)
|
||||
agg = (await db.execute(
|
||||
select(func.count(SegmentEffort.id), func.min(SegmentEffort.duration_s))
|
||||
.where(SegmentEffort.segment_id == seg.id)
|
||||
)).one()
|
||||
return SegmentOut(
|
||||
id=seg.id, name=seg.name, sport_type=seg.sport_type, polyline=seg.polyline,
|
||||
distance_m=seg.distance_m, created_from_activity_id=seg.created_from_activity_id,
|
||||
effort_count=agg[0] or 0, best_s=agg[1],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{segment_id}", status_code=204)
|
||||
async def delete_segment(
|
||||
segment_id: int,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from jose import jwt, JWTError
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user, create_access_token
|
||||
from app.core.config import settings
|
||||
from app.models.user import User, StravaConfig
|
||||
from app.services.strava_sync import (
|
||||
STRAVA_AUTHORIZE_URL, STRAVA_SCOPE, exchange_code, encrypt_token,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STATE_PURPOSE = "strava-link"
|
||||
|
||||
|
||||
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"strava_sync_task:{user_id}"
|
||||
|
||||
|
||||
def sync_cancel_key(user_id: int) -> str:
|
||||
return f"strava_sync_cancel:{user_id}"
|
||||
|
||||
|
||||
def _redirect_uri() -> str:
|
||||
return f"{settings.base_url.rstrip('/')}/api/strava-sync/callback"
|
||||
|
||||
|
||||
class StravaConfigIn(BaseModel):
|
||||
sync_enabled: bool = True
|
||||
sync_lookback_days: int = 30
|
||||
|
||||
|
||||
class StravaConfigOut(BaseModel):
|
||||
connected: bool
|
||||
athlete_name: Optional[str] = None
|
||||
sync_enabled: bool = False
|
||||
sync_lookback_days: int = 30
|
||||
sync_interval_minutes: int = settings.garmin_sync_interval_minutes
|
||||
last_sync_at: Optional[datetime] = None
|
||||
last_sync_status: Optional[str] = None
|
||||
configured: bool = True # whether the server has Strava API credentials at all
|
||||
|
||||
|
||||
def _out(cfg: Optional[StravaConfig]) -> StravaConfigOut:
|
||||
configured = bool(settings.strava_client_id and settings.strava_client_secret)
|
||||
if not cfg:
|
||||
return StravaConfigOut(
|
||||
connected=False, sync_enabled=False, sync_lookback_days=30,
|
||||
sync_interval_minutes=settings.garmin_sync_interval_minutes,
|
||||
configured=configured,
|
||||
)
|
||||
return StravaConfigOut(
|
||||
connected=True,
|
||||
athlete_name=cfg.athlete_name,
|
||||
sync_enabled=cfg.sync_enabled,
|
||||
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
|
||||
sync_interval_minutes=settings.garmin_sync_interval_minutes,
|
||||
last_sync_at=cfg.last_sync_at,
|
||||
last_sync_status=cfg.last_sync_status,
|
||||
configured=configured,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config", response_model=StravaConfigOut)
|
||||
async def get_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
cfg = (await db.execute(
|
||||
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
return _out(cfg)
|
||||
|
||||
|
||||
@router.get("/authorize")
|
||||
async def authorize(current_user: User = Depends(get_current_user)):
|
||||
"""Return the Strava OAuth URL to redirect the browser to. A signed `state`
|
||||
carries the user id through the callback (which has no JWT header)."""
|
||||
if not (settings.strava_client_id and settings.strava_client_secret):
|
||||
raise HTTPException(status_code=400, detail="Strava API is not configured on this server")
|
||||
state = create_access_token(
|
||||
{"sub": str(current_user.id), "purpose": STATE_PURPOSE},
|
||||
expires_delta=timedelta(minutes=10),
|
||||
)
|
||||
params = {
|
||||
"client_id": settings.strava_client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": _redirect_uri(),
|
||||
"approval_prompt": "auto",
|
||||
"scope": STRAVA_SCOPE,
|
||||
"state": state,
|
||||
}
|
||||
return {"url": f"{STRAVA_AUTHORIZE_URL}?{urlencode(params)}"}
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(
|
||||
code: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Strava redirects here after the user authorizes. Exchange the code, store
|
||||
tokens, then bounce back to the SPA profile page."""
|
||||
profile_url = f"{settings.base_url.rstrip('/')}/profile"
|
||||
|
||||
if error or not code:
|
||||
return RedirectResponse(f"{profile_url}?strava=error")
|
||||
|
||||
# Validate state → user id
|
||||
try:
|
||||
payload = jwt.decode(state or "", settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("purpose") != STATE_PURPOSE:
|
||||
raise ValueError("bad purpose")
|
||||
user_id = int(payload["sub"])
|
||||
except (JWTError, KeyError, TypeError, ValueError):
|
||||
return RedirectResponse(f"{profile_url}?strava=error")
|
||||
|
||||
# Require activity:read_all so private activities sync too.
|
||||
if scope and "activity:read_all" not in scope:
|
||||
return RedirectResponse(f"{profile_url}?strava=scope")
|
||||
|
||||
try:
|
||||
tok = exchange_code(code)
|
||||
except Exception:
|
||||
return RedirectResponse(f"{profile_url}?strava=error")
|
||||
|
||||
athlete = tok.get("athlete") or {}
|
||||
athlete_name = " ".join(
|
||||
x for x in [athlete.get("firstname"), athlete.get("lastname")] if x
|
||||
).strip() or (athlete.get("username") or None)
|
||||
|
||||
cfg = (await db.execute(
|
||||
select(StravaConfig).where(StravaConfig.user_id == user_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
expires_at = datetime.fromtimestamp(tok["expires_at"], tz=timezone.utc)
|
||||
if cfg:
|
||||
cfg.access_token_enc = encrypt_token(tok["access_token"])
|
||||
cfg.refresh_token_enc = encrypt_token(tok["refresh_token"])
|
||||
cfg.expires_at = expires_at
|
||||
cfg.athlete_id = str(athlete.get("id") or "") or cfg.athlete_id
|
||||
cfg.athlete_name = athlete_name or cfg.athlete_name
|
||||
cfg.last_sync_status = "Connected"
|
||||
else:
|
||||
cfg = StravaConfig(
|
||||
user_id=user_id,
|
||||
athlete_id=str(athlete.get("id") or "") or None,
|
||||
athlete_name=athlete_name,
|
||||
access_token_enc=encrypt_token(tok["access_token"]),
|
||||
refresh_token_enc=encrypt_token(tok["refresh_token"]),
|
||||
expires_at=expires_at,
|
||||
sync_enabled=True,
|
||||
sync_lookback_days=30,
|
||||
last_sync_status="Connected",
|
||||
)
|
||||
db.add(cfg)
|
||||
await db.commit()
|
||||
|
||||
# Kick off an initial sync immediately.
|
||||
try:
|
||||
from app.workers.tasks import sync_strava_user
|
||||
sync_strava_user.delay(user_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return RedirectResponse(f"{profile_url}?strava=connected")
|
||||
|
||||
|
||||
@router.put("/config", response_model=StravaConfigOut)
|
||||
async def save_config(
|
||||
body: StravaConfigIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
cfg = (await db.execute(
|
||||
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=400, detail="Strava is not connected")
|
||||
|
||||
# Asking for more history than before → reset last_sync_at so the next sync
|
||||
# backfills the wider window (mirrors the Garmin behaviour).
|
||||
old = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
|
||||
new = body.sync_lookback_days
|
||||
wants_more = (new != old) and (new == -1 or (old != -1 and new > old))
|
||||
if wants_more:
|
||||
cfg.last_sync_at = None
|
||||
cfg.last_sync_status = "Lookback increased — backfill on next sync"
|
||||
|
||||
cfg.sync_enabled = body.sync_enabled
|
||||
cfg.sync_lookback_days = body.sync_lookback_days
|
||||
await db.commit()
|
||||
await db.refresh(cfg)
|
||||
return _out(cfg)
|
||||
|
||||
|
||||
@router.delete("/config")
|
||||
async def delete_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
cfg = (await db.execute(
|
||||
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
if cfg:
|
||||
await db.delete(cfg)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/trigger")
|
||||
async def trigger_sync(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
cfg = (await db.execute(
|
||||
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
if not cfg or not cfg.sync_enabled:
|
||||
raise HTTPException(status_code=400, detail="Strava sync is not configured or disabled")
|
||||
|
||||
from app.workers.tasks import sync_strava_user
|
||||
task = sync_strava_user.delay(current_user.id)
|
||||
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),
|
||||
):
|
||||
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
|
||||
celery_app.control.revoke(tid, terminate=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cfg = (await db.execute(
|
||||
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
if cfg:
|
||||
cfg.last_sync_status = "Cancelling…"
|
||||
await db.commit()
|
||||
return {"status": "cancelling"}
|
||||
+141
-15
@@ -3,12 +3,13 @@ import zipfile
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.workers.tasks import process_activity_file, process_garmin_health_zip
|
||||
from app.workers.tasks import process_activity_file, process_garmin_health_zip, analyze_strava_export
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -16,6 +17,34 @@ MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB upload cap
|
||||
MAX_EXTRACT_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB total uncompressed cap (zip-bomb guard)
|
||||
_CHUNK = 1024 * 1024
|
||||
|
||||
_TASK_OWNER_TTL = 86400 # 24h — long enough to outlive any upload's polling
|
||||
|
||||
|
||||
def _remember_task_owner(task_id: str, user_id: int) -> None:
|
||||
"""Record which user a pollable task belongs to, so the status endpoint can
|
||||
refuse to surface another user's task result (the Celery task id is the only
|
||||
thing the client presents). Best-effort: Redis hiccups must not fail uploads."""
|
||||
if not task_id:
|
||||
return
|
||||
try:
|
||||
import redis as redis_lib
|
||||
redis_lib.Redis.from_url(settings.redis_url).set(
|
||||
f"upload_task_owner:{task_id}", user_id, ex=_TASK_OWNER_TTL
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _task_owner(task_id: str) -> int | None:
|
||||
try:
|
||||
import redis as redis_lib
|
||||
v = redis_lib.Redis.from_url(settings.redis_url).get(f"upload_task_owner:{task_id}")
|
||||
if v is None:
|
||||
return None
|
||||
return int(v.decode() if isinstance(v, (bytes, bytearray)) else v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_name(filename: str) -> str:
|
||||
"""Reduce an uploaded filename to a safe basename — no path traversal."""
|
||||
@@ -74,6 +103,33 @@ def _safe_extract(zf: zipfile.ZipFile, dest_dir: Path) -> list[Path]:
|
||||
return extracted
|
||||
|
||||
|
||||
def _gunzip(path: Path) -> Path | None:
|
||||
"""Decompress a .gz member to a sibling file without the .gz suffix,
|
||||
enforcing the same uncompressed-size cap. Returns the new path, or None on
|
||||
failure. The .gz is removed once expanded."""
|
||||
import gzip
|
||||
out_path = path.with_suffix("") # strips the trailing .gz
|
||||
try:
|
||||
total = 0
|
||||
with gzip.open(path, "rb") as src, open(out_path, "wb") as out:
|
||||
while True:
|
||||
chunk = src.read(_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_EXTRACT_SIZE:
|
||||
out.close()
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
out.write(chunk)
|
||||
except (OSError, EOFError):
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
return out_path
|
||||
|
||||
|
||||
@router.post("/activity")
|
||||
async def upload_activity(
|
||||
file: UploadFile = File(...),
|
||||
@@ -81,16 +137,17 @@ async def upload_activity(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload a single .fit or .gpx activity file."""
|
||||
"""Upload a single .fit, .gpx or .tcx activity file."""
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
if suffix not in {".fit", ".gpx"}:
|
||||
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
|
||||
if suffix not in {".fit", ".gpx", ".tcx"}:
|
||||
raise HTTPException(status_code=400, detail="Only .fit, .gpx and .tcx files are supported")
|
||||
|
||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
|
||||
dest = save_upload(file, dest_dir)
|
||||
|
||||
# Queue processing
|
||||
task = process_activity_file.delay(str(dest), current_user.id, suffix[1:])
|
||||
_remember_task_owner(task.id, current_user.id)
|
||||
|
||||
return {"task_id": task.id, "status": "queued", "filename": file.filename}
|
||||
|
||||
@@ -152,6 +209,7 @@ async def upload_garmin_export(
|
||||
|
||||
# Queue health/wellness data extraction
|
||||
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
||||
_remember_task_owner(health_task.id, current_user.id)
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
@@ -160,13 +218,39 @@ async def upload_garmin_export(
|
||||
}
|
||||
|
||||
|
||||
def _collect_activity_files(extracted: list[Path]) -> list[Path]:
|
||||
"""From extracted archive members, gunzip any .gz wrappers (Strava ships most
|
||||
activities as <id>.fit.gz / .gpx.gz / .tcx.gz) and return the .fit/.gpx/.tcx
|
||||
paths ready to parse. Without this, the gzipped majority is silently skipped."""
|
||||
files: list[Path] = []
|
||||
for path in extracted:
|
||||
if path.suffix.lower() == ".gz":
|
||||
inner = _gunzip(path)
|
||||
if inner is None:
|
||||
continue
|
||||
path = inner
|
||||
if path.suffix.lower() in (".fit", ".gpx", ".tcx"):
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
class StravaConfirmIn(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@router.post("/strava-export")
|
||||
async def upload_strava_export(
|
||||
file: UploadFile = File(...),
|
||||
dry_run: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files)."""
|
||||
"""Upload a Strava bulk export ZIP (activities/ folder with GPX/FIT/TCX files).
|
||||
|
||||
With `dry_run=true` nothing is imported: the archive is extracted and a single
|
||||
analysis task reports how many activities are new vs. already present (Garmin
|
||||
data is preferred), returning a `token` to confirm the real import with.
|
||||
Otherwise activities import straight away (existing data is kept on a match)."""
|
||||
if not file.filename.endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
|
||||
|
||||
@@ -175,7 +259,6 @@ 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)
|
||||
@@ -183,31 +266,74 @@ async def upload_strava_export(
|
||||
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()
|
||||
if suffix in (".fit", ".gpx"):
|
||||
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
|
||||
task_ids.append(task.id)
|
||||
|
||||
if not task_ids:
|
||||
files = _collect_activity_files(extracted)
|
||||
if not files:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No activity files (.fit or .gpx) found in this Strava archive",
|
||||
detail="No activity files (.fit, .gpx or .tcx) found in this Strava archive",
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
# Preview only — classify new/duplicate without writing. The extracted
|
||||
# files are kept so the confirm step can import them without re-uploading.
|
||||
task = analyze_strava_export.delay([str(p) for p in files], current_user.id)
|
||||
_remember_task_owner(task.id, current_user.id)
|
||||
return {"status": "analyzing", "task_id": task.id, "token": dest.stem,
|
||||
"activity_files": len(files)}
|
||||
|
||||
task_ids = [
|
||||
process_activity_file.delay(str(p), current_user.id, p.suffix.lower()[1:],
|
||||
prefer_existing=True).id
|
||||
for p in files
|
||||
]
|
||||
polled = task_ids[-1] if task_ids else None
|
||||
_remember_task_owner(polled, current_user.id)
|
||||
return {
|
||||
"status": "queued",
|
||||
"activity_tasks": len(task_ids),
|
||||
"task_id": task_ids[-1] if task_ids else None,
|
||||
"task_id": polled,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/strava-export/confirm")
|
||||
async def confirm_strava_export(
|
||||
body: StravaConfirmIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Import the activities from a previously previewed (dry-run) Strava export.
|
||||
`token` is the value returned by the dry-run upload; the already-extracted
|
||||
files are imported with Garmin data preferred over Strava duplicates."""
|
||||
token = _safe_name(body.token)
|
||||
extract_dir = Path(settings.file_store_path) / str(current_user.id) / "exports" / f"strava_{token}"
|
||||
if not extract_dir.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Preview not found — please upload and preview again")
|
||||
|
||||
files = [p for p in extract_dir.rglob("*")
|
||||
if p.is_file() and p.suffix.lower() in (".fit", ".gpx", ".tcx")]
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="No activity files found for this preview")
|
||||
|
||||
task_ids = [
|
||||
process_activity_file.delay(str(p), current_user.id, p.suffix.lower()[1:],
|
||||
prefer_existing=True).id
|
||||
for p in files
|
||||
]
|
||||
return {"status": "queued", "activity_tasks": len(task_ids)}
|
||||
|
||||
|
||||
@router.get("/task/{task_id}")
|
||||
async def check_task_status(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Check the status of an upload processing task."""
|
||||
# A task result can carry the owner's activity data, so don't surface another
|
||||
# user's task. We fail closed only on a positive owner mismatch; a missing
|
||||
# record (Redis down / TTL expired) stays permissive so polling never breaks.
|
||||
owner = _task_owner(task_id)
|
||||
if owner is not None and owner != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
result = celery_app.AsyncResult(task_id)
|
||||
return {
|
||||
|
||||
@@ -19,7 +19,8 @@ from app.core.security import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import (
|
||||
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
|
||||
Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
|
||||
Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog,
|
||||
GarminConnectConfig, StravaConfig,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -134,6 +135,11 @@ async def delete_user(
|
||||
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
|
||||
await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id))
|
||||
await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id))
|
||||
# StravaConfig holds Fernet-encrypted OAuth tokens and has a NOT-NULL FK to
|
||||
# users with no DB-level cascade; the Core deletes above bypass the ORM
|
||||
# relationship cascade, so it must be removed explicitly or the final
|
||||
# DELETE on users raises a ForeignKey violation (and leaves tokens orphaned).
|
||||
await db.execute(delete(StravaConfig).where(StravaConfig.user_id == user_id))
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -24,6 +24,15 @@ class Settings(BaseSettings):
|
||||
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
|
||||
# Garmin Connect — how often the beat scheduler runs the automatic sync
|
||||
garmin_sync_interval_minutes: int = Field(30, env="GARMIN_SYNC_INTERVAL_MINUTES")
|
||||
# Strava API (optional) — register an app at https://www.strava.com/settings/api.
|
||||
# The Authorization Callback Domain there must match BASE_URL's host.
|
||||
strava_client_id: Optional[str] = Field(None, env="STRAVA_CLIENT_ID")
|
||||
strava_client_secret: Optional[str] = Field(None, env="STRAVA_CLIENT_SECRET")
|
||||
# Default Thunderforest tile API key, served to clients that haven't set
|
||||
# their own (a public, client-side tile key). Override per-deployment.
|
||||
thunderforest_default_key: str = Field(
|
||||
"872984f587484873a74ea454662ffacb", env="THUNDERFOREST_DEFAULT_KEY"
|
||||
)
|
||||
# Files
|
||||
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
||||
# Environment
|
||||
|
||||
@@ -18,6 +18,13 @@ def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def dummy_verify_password() -> None:
|
||||
"""Run a throwaway bcrypt verification so the username-not-found login path
|
||||
takes the same time as a real password check, preventing username
|
||||
enumeration via response-timing differences."""
|
||||
pwd_context.dummy_verify()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
+46
-1
@@ -6,7 +6,7 @@ import asyncio
|
||||
|
||||
from app.core.database import engine, AsyncSessionLocal, Base
|
||||
from app.core.config import settings
|
||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users, segments
|
||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, strava_sync, users, segments
|
||||
|
||||
|
||||
async def init_db():
|
||||
@@ -56,6 +56,12 @@ async def init_db():
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS moving_time_s FLOAT"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS original_name VARCHAR(256)"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS active_spans JSON"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"activities.moving_time_s column migration skipped: {e}")
|
||||
|
||||
@@ -68,6 +74,9 @@ async def init_db():
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS hrv_baseline_low FLOAT",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS hrv_baseline_upper FLOAT",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS hrv_weekly_avg FLOAT",
|
||||
]:
|
||||
await conn.execute(text(stmt))
|
||||
except Exception as e:
|
||||
@@ -109,6 +118,15 @@ async def init_db():
|
||||
except Exception as e:
|
||||
print(f"users.dashboard_layout column migration skipped: {e}")
|
||||
|
||||
# map_settings column on users added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS map_settings JSON"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"users.map_settings column migration skipped: {e}")
|
||||
|
||||
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -180,6 +198,20 @@ async def init_db():
|
||||
except Exception as e:
|
||||
print(f"avg_speed_ms fix skipped: {e}")
|
||||
|
||||
# An auto-detected route the user has renamed should count as a custom route.
|
||||
# Auto names are generated as "<Sport> route <DD Mon YYYY>"; any auto_detected
|
||||
# route whose name no longer matches that pattern has been renamed → mark it
|
||||
# custom so it groups with user-named routes.
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"UPDATE named_routes SET auto_detected = false "
|
||||
"WHERE auto_detected = true "
|
||||
"AND name !~ '^.+ route [0-9]{1,2} [A-Za-z]{3} [0-9]{4}$'"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"route auto_detected backfill skipped: {e}")
|
||||
|
||||
# Seed admin user (only if password is configured)
|
||||
if not settings.admin_password:
|
||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||
@@ -231,6 +263,18 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def no_store_api_responses(request, call_next):
|
||||
"""Authenticated API data must never be cached by the browser/proxy. Without
|
||||
this, browsers (notably Edge) can heuristically cache GETs like
|
||||
/api/garmin-sync/config and keep showing stale state (e.g. "not connected")
|
||||
until a manual cache clear."""
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/api/"):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
|
||||
app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
|
||||
@@ -239,6 +283,7 @@ app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
||||
app.include_router(strava_sync.router, prefix="/api/strava-sync", tags=["strava-sync"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
app.include_router(segments.router, prefix="/api/segments", tags=["segments"])
|
||||
|
||||
|
||||
@@ -40,11 +40,16 @@ class User(Base):
|
||||
# Saved dashboard widget layout (react-grid-layout array). Null = use default.
|
||||
dashboard_layout = Column(JSON, nullable=True)
|
||||
|
||||
# Global map tile preference: {provider, style, keys:{thunderforest, maptiler}}.
|
||||
# Null = use defaults. Tile API keys are public client-side keys.
|
||||
map_settings = Column(JSON, nullable=True)
|
||||
|
||||
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
||||
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
strava_config = relationship("StravaConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class GarminConnectConfig(Base):
|
||||
@@ -67,6 +72,27 @@ class GarminConnectConfig(Base):
|
||||
user = relationship("User", back_populates="garmin_connect_config")
|
||||
|
||||
|
||||
class StravaConfig(Base):
|
||||
"""Per-user Strava OAuth tokens and sync state. Tokens are Fernet-encrypted
|
||||
(same SECRET_KEY scheme as Garmin credentials)."""
|
||||
__tablename__ = "strava_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True)
|
||||
athlete_id = Column(String(64), nullable=True)
|
||||
athlete_name = Column(String(256), nullable=True)
|
||||
access_token_enc = Column(String(512), nullable=False)
|
||||
refresh_token_enc = Column(String(512), nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True) # access-token expiry
|
||||
sync_enabled = Column(Boolean, default=True)
|
||||
sync_lookback_days = Column(Integer, default=30) # -1 = all-time, first sync only
|
||||
last_sync_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_sync_status = Column(String(512), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
user = relationship("User", back_populates="strava_config")
|
||||
|
||||
|
||||
class WeightLog(Base):
|
||||
"""Manual weight entries separate from health_metrics for easy tracking."""
|
||||
__tablename__ = "weight_logs"
|
||||
@@ -91,6 +117,7 @@ class Activity(Base):
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(256), nullable=False)
|
||||
original_name = Column(String(256), nullable=True) # Garmin/import title, kept when user renames
|
||||
sport_type = Column(String(64), nullable=False)
|
||||
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||
@@ -106,6 +133,11 @@ class Activity(Base):
|
||||
normalized_power = Column(Float, nullable=True)
|
||||
avg_speed_ms = Column(Float, nullable=True)
|
||||
max_speed_ms = Column(Float, nullable=True)
|
||||
# When recording was paused/resumed (e.g. a long lunch break mid-ride) the
|
||||
# device leaves a gap in the data stream. Stored as a list of active
|
||||
# [start_ms, end_ms] epoch spans (only set when a >5min gap splits the
|
||||
# recording into 2+ spans); null means one continuous recording.
|
||||
active_spans = Column(JSON, nullable=True)
|
||||
avg_temperature_c = Column(Float, nullable=True)
|
||||
calories = Column(Float, nullable=True)
|
||||
training_stress_score = Column(Float, nullable=True)
|
||||
@@ -125,6 +157,16 @@ class Activity(Base):
|
||||
named_route = relationship("NamedRoute", back_populates="activities")
|
||||
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def named_route_name(self):
|
||||
"""Name of the associated NamedRoute, or None. Reads the relationship only
|
||||
if it was eager-loaded (selectinload) so it never triggers a lazy load in
|
||||
the async request context — returns None when unloaded."""
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
if "named_route" in sa_inspect(self).unloaded:
|
||||
return None
|
||||
return self.named_route.name if self.named_route else None
|
||||
|
||||
|
||||
class ActivityDataPoint(Base):
|
||||
__tablename__ = "activity_data_points"
|
||||
@@ -251,9 +293,12 @@ class HealthMetric(Base):
|
||||
max_hr_day = Column(Float, nullable=True)
|
||||
avg_hr_day = Column(Float, nullable=True)
|
||||
hrv_status = Column(String(32), nullable=True)
|
||||
hrv_nightly_avg = Column(Float, nullable=True)
|
||||
hrv_nightly_avg = Column(Float, nullable=True) # last single night's avg (Garmin lastNightAvg)
|
||||
hrv_weekly_avg = Column(Float, nullable=True) # overnight/weekly avg Garmin plots on its HRV Status chart
|
||||
hrv_5min_high = Column(Float, nullable=True)
|
||||
hrv_5min_low = Column(Float, nullable=True)
|
||||
hrv_baseline_low = Column(Float, nullable=True) # Garmin balanced range lower bound (balancedLow)
|
||||
hrv_baseline_upper = Column(Float, nullable=True) # Garmin balanced range upper bound (balancedUpper)
|
||||
sleep_duration_s = Column(Float, nullable=True)
|
||||
sleep_deep_s = Column(Float, nullable=True)
|
||||
sleep_light_s = Column(Float, nullable=True)
|
||||
|
||||
@@ -71,6 +71,42 @@ def _vehicle_reason(sport_type, avg_speed_ms, dist_m=None, dur_s=None) -> Option
|
||||
return None
|
||||
|
||||
|
||||
# Recording gaps longer than this split an activity into separate "active" spans.
|
||||
# Auto-pause at traffic lights produces sub-minute gaps; a genuine break (a meal
|
||||
# stop on a long ride, etc.) leaves a multi-minute hole in the stream.
|
||||
PAUSE_GAP_S = 300
|
||||
|
||||
|
||||
def _active_spans(points):
|
||||
"""From normalised data points, return a list of active [start_ms, end_ms]
|
||||
epoch spans, splitting wherever the recording paused for more than
|
||||
PAUSE_GAP_S. Returns None when the recording is one continuous span (the
|
||||
common case) so the payload stays small."""
|
||||
ts = []
|
||||
for p in points:
|
||||
t = p.get("timestamp")
|
||||
if not t:
|
||||
continue
|
||||
try:
|
||||
ts.append(int(datetime.fromisoformat(t).timestamp() * 1000))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if len(ts) < 2:
|
||||
return None
|
||||
ts.sort()
|
||||
gap_ms = PAUSE_GAP_S * 1000
|
||||
spans = []
|
||||
span_start = ts[0]
|
||||
prev = ts[0]
|
||||
for t in ts[1:]:
|
||||
if t - prev > gap_ms:
|
||||
spans.append([span_start, prev])
|
||||
span_start = t
|
||||
prev = t
|
||||
spans.append([span_start, prev])
|
||||
return spans if len(spans) > 1 else None
|
||||
|
||||
|
||||
def _bounding_box(coords):
|
||||
if not coords:
|
||||
return None
|
||||
@@ -241,9 +277,13 @@ def parse_fit_file(filepath: str) -> dict:
|
||||
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"))
|
||||
# When the FIT avgSpeed is missing/invalid we fall back to distance/time.
|
||||
# Prefer moving (timer) time so the figure matches Garmin's moving-average
|
||||
# semantics — using elapsed time badly understates pace on rides with a long
|
||||
# mid-activity pause (e.g. a 5h elapsed / 1h48 moving ride).
|
||||
avg_speed = _sanitize_speed(
|
||||
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
|
||||
dist_m=total_dist, dur_s=elapsed_s,
|
||||
dist_m=total_dist, dur_s=moving_s or elapsed_s,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -271,6 +311,7 @@ def parse_fit_file(filepath: str) -> dict:
|
||||
"total_training_effect")),
|
||||
"polyline": encoded_polyline,
|
||||
"bounding_box": bounding_box,
|
||||
"active_spans": _active_spans(normalized_points),
|
||||
"source_type": "fit",
|
||||
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
|
||||
"data_points": normalized_points,
|
||||
@@ -358,12 +399,150 @@ def parse_gpx_file(filepath: str) -> dict:
|
||||
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
|
||||
"training_stress_score": None, "vo2max_estimate": None,
|
||||
"polyline": encoded_polyline, "bounding_box": bounding_box,
|
||||
"active_spans": _active_spans(data_points),
|
||||
"source_type": "gpx",
|
||||
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
|
||||
"data_points": data_points, "laps": [],
|
||||
}
|
||||
|
||||
|
||||
def parse_tcx_file(filepath: str) -> dict:
|
||||
"""Parse a Garmin Training Center XML (.tcx) activity file.
|
||||
|
||||
Strava bulk exports include older device uploads as .tcx (often gzipped).
|
||||
TCX is namespaced XML; we match by local tag name so the parser is robust to
|
||||
the various TCX namespace declarations in the wild. Output mirrors
|
||||
parse_gpx_file so downstream ingestion is identical."""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
def local(tag: str) -> str:
|
||||
return tag.split("}")[-1] if "}" in tag else tag
|
||||
|
||||
def find(el, name):
|
||||
for child in el.iter():
|
||||
if local(child.tag) == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
def findall(el, name):
|
||||
return [c for c in el.iter() if local(c.tag) == name]
|
||||
|
||||
def child_text(el, name):
|
||||
for c in list(el):
|
||||
if local(c.tag) == name and c.text:
|
||||
return c.text.strip()
|
||||
return None
|
||||
|
||||
tree = ET.parse(filepath)
|
||||
root = tree.getroot()
|
||||
|
||||
activity_el = find(root, "Activity")
|
||||
sport_raw = (activity_el.get("Sport") if activity_el is not None else None) or "Other"
|
||||
sport = {"running": "running", "biking": "cycling",
|
||||
"walking": "walking", "hiking": "hiking"}.get(sport_raw.lower(), sport_raw.lower())
|
||||
|
||||
data_points = []
|
||||
for tp in findall(root, "Trackpoint"):
|
||||
ts_str = child_text(tp, "Time")
|
||||
ts = None
|
||||
if ts_str:
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
ts = None
|
||||
|
||||
lat = lng = None
|
||||
pos = next((c for c in list(tp) if local(c.tag) == "Position"), None)
|
||||
if pos is not None:
|
||||
lat = _safe_float(child_text(pos, "LatitudeDegrees"))
|
||||
lng = _safe_float(child_text(pos, "LongitudeDegrees"))
|
||||
|
||||
hr = None
|
||||
hr_el = next((c for c in list(tp) if local(c.tag) == "HeartRateBpm"), None)
|
||||
if hr_el is not None:
|
||||
hr = _safe_float(child_text(hr_el, "Value"))
|
||||
|
||||
# Speed/Watts live in a TPX extension; search descendants by local name.
|
||||
speed = watts = None
|
||||
for ext in findall(tp, "Speed"):
|
||||
speed = _safe_float(ext.text)
|
||||
break
|
||||
for ext in findall(tp, "Watts"):
|
||||
watts = _safe_float(ext.text)
|
||||
break
|
||||
|
||||
data_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": lat, "longitude": lng,
|
||||
"altitude_m": _safe_float(child_text(tp, "AltitudeMeters")),
|
||||
"heart_rate": hr,
|
||||
"cadence": _safe_float(child_text(tp, "Cadence")),
|
||||
"speed_ms": speed,
|
||||
"power": watts,
|
||||
"temperature_c": None,
|
||||
"distance_m": _safe_float(child_text(tp, "DistanceMeters")),
|
||||
})
|
||||
|
||||
coords = [(p["latitude"], p["longitude"]) for p in data_points if p["latitude"] and p["longitude"]]
|
||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
# Distance: prefer the cumulative DistanceMeters from the file; fall back to
|
||||
# haversine over GPS points when absent (some TCX trackpoints omit it).
|
||||
dist_vals = [p["distance_m"] for p in data_points if p["distance_m"] is not None]
|
||||
if dist_vals:
|
||||
total_dist = max(dist_vals)
|
||||
else:
|
||||
total_dist = 0.0
|
||||
prev = None
|
||||
for p in data_points:
|
||||
if p["latitude"] and p["longitude"]:
|
||||
if prev:
|
||||
R = 6371000
|
||||
phi1, phi2 = math.radians(prev[0]), math.radians(p["latitude"])
|
||||
dphi = math.radians(p["latitude"] - prev[0])
|
||||
dlam = math.radians(p["longitude"] - prev[1])
|
||||
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
||||
total_dist += 2 * R * math.asin(math.sqrt(a))
|
||||
prev = (p["latitude"], p["longitude"])
|
||||
p["distance_m"] = total_dist
|
||||
|
||||
uphill, downhill = 0.0, 0.0
|
||||
alts = [p["altitude_m"] for p in data_points if p["altitude_m"] is not None]
|
||||
for i in range(1, len(alts)):
|
||||
diff = alts[i] - alts[i-1]
|
||||
if diff > 0: uphill += diff
|
||||
else: downhill += abs(diff)
|
||||
|
||||
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
||||
start_time_str = next((p["timestamp"] for p in data_points if p["timestamp"]), None)
|
||||
last_time_str = next((p["timestamp"] for p in reversed(data_points) if p["timestamp"]), None)
|
||||
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
|
||||
end_dt = datetime.fromisoformat(last_time_str) if last_time_str else None
|
||||
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
||||
avg_speed = (total_dist / duration) if (total_dist and duration) else None
|
||||
|
||||
return {
|
||||
"name": f"{sport.title()} {start_dt.date() if start_dt else ''}".strip(),
|
||||
"sport_type": sport, "start_time": start_time_str,
|
||||
"distance_m": total_dist or None, "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": 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,
|
||||
"active_spans": _active_spans(data_points),
|
||||
"source_type": "tcx",
|
||||
"rejected_reason": _vehicle_reason(sport, avg_speed, total_dist, duration),
|
||||
"data_points": data_points, "laps": [],
|
||||
}
|
||||
|
||||
|
||||
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||
if not user_max_hr or user_max_hr < 100:
|
||||
return {}
|
||||
|
||||
@@ -133,18 +133,24 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
|
||||
continue
|
||||
|
||||
# Slow-path dedup: activity imported via bulk export (no garmin_activity_id).
|
||||
# Check by start_time; stamp the ID so future syncs skip it in the fast path.
|
||||
act_start_str = act.get("startTimeLocal") or act.get("startTimeGMT") or ""
|
||||
# Match on the actual start instant (not just the date — two activities on
|
||||
# the same day are distinct), comparing GMT-to-GMT since FIT start_times are
|
||||
# stored in UTC. A small window absorbs sub-second/rounding differences.
|
||||
act_start_str = act.get("startTimeGMT") or ""
|
||||
if act_start_str:
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
from datetime import datetime as _dt, timezone as _tz
|
||||
act_start = _dt.fromisoformat(act_start_str.replace("Z", "+00:00"))
|
||||
if act_start.tzinfo is None:
|
||||
act_start = act_start.replace(tzinfo=_tz.utc) # startTimeGMT is UTC
|
||||
window = timedelta(minutes=5)
|
||||
time_match = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
func.date(Activity.start_time) == act_start.date(),
|
||||
Activity.start_time >= act_start - window,
|
||||
Activity.start_time <= act_start + window,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
).scalars().first()
|
||||
if time_match:
|
||||
if not time_match.garmin_activity_id:
|
||||
time_match.garmin_activity_id = garmin_id
|
||||
@@ -598,10 +604,15 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
||||
if hrv_data:
|
||||
summary = hrv_data.get("hrvSummary") or hrv_data
|
||||
_set(row, "hrv_nightly_avg", summary.get("lastNight") or summary.get("lastNightAvg"))
|
||||
_set(row, "hrv_weekly_avg", summary.get("weeklyAvg"))
|
||||
_set(row, "hrv_5min_high", summary.get("lastNight5MinHigh"))
|
||||
status = summary.get("status")
|
||||
if status:
|
||||
row["hrv_status"] = str(status).lower()
|
||||
# Garmin's per-day balanced baseline range (the grey band in the app).
|
||||
baseline = summary.get("baseline") or {}
|
||||
_set(row, "hrv_baseline_low", baseline.get("balancedLow"))
|
||||
_set(row, "hrv_baseline_upper", baseline.get("balancedUpper"))
|
||||
|
||||
return row
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Strava API sync helpers.
|
||||
|
||||
OAuth: exchange_code() / refresh_tokens() talk to Strava's token endpoint.
|
||||
get_valid_access_token() transparently refreshes an expired access token and
|
||||
persists the rotated refresh token.
|
||||
|
||||
sync_strava_activities() lists the athlete's activities, fetches per-activity
|
||||
streams, builds a fit_parser-shaped dict and hands it to persist_activity()
|
||||
(shared with the file-upload path) so Strava activities get identical dedup,
|
||||
PR, route and segment handling.
|
||||
|
||||
Tokens are Fernet-encrypted with the same SECRET_KEY scheme as Garmin creds.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import polyline as polyline_lib
|
||||
|
||||
from app.services.fit_parser import _bounding_box, _active_spans
|
||||
from app.services.garmin_connect_sync import _fernet # reuse the SECRET_KEY-derived Fernet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STRAVA_AUTHORIZE_URL = "https://www.strava.com/oauth/authorize"
|
||||
STRAVA_TOKEN_URL = "https://www.strava.com/oauth/token"
|
||||
STRAVA_API_BASE = "https://www.strava.com/api/v3"
|
||||
|
||||
# Scope needed to read all activities (including those marked "Only You").
|
||||
STRAVA_SCOPE = "read,activity:read_all"
|
||||
|
||||
# Like Garmin: incremental syncs only re-scan the last day or two for late edits.
|
||||
INCREMENTAL_BUFFER_DAYS = 1
|
||||
|
||||
# Strava activity type / sport_type → MileVault internal sport_type. Gym types map
|
||||
# onto the internal vocabulary so sportColor() paints them red, GPS types green/etc.
|
||||
STRAVA_SPORT_MAP = {
|
||||
"run": "running", "trailrun": "running", "virtualrun": "running",
|
||||
"ride": "cycling", "virtualride": "cycling", "mountainbikeride": "cycling",
|
||||
"gravelride": "cycling", "ebikeride": "cycling", "emountainbikeride": "cycling",
|
||||
"handcycle": "cycling", "velomobile": "cycling",
|
||||
"walk": "walking", "hike": "hiking",
|
||||
"swim": "swimming",
|
||||
"weighttraining": "strength_training", "workout": "training",
|
||||
"crossfit": "hiit", "hiit": "hiit",
|
||||
"elliptical": "fitness_equipment", "stairstepper": "fitness_equipment",
|
||||
"golf": "golf", "yoga": "yoga",
|
||||
}
|
||||
|
||||
|
||||
def encrypt_token(token: str) -> str:
|
||||
return _fernet().encrypt(token.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_token(enc: str) -> str:
|
||||
return _fernet().decrypt(enc.encode()).decode()
|
||||
|
||||
|
||||
# ── OAuth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _token_request(payload: dict) -> dict:
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
"client_id": settings.strava_client_id,
|
||||
"client_secret": settings.strava_client_secret,
|
||||
**payload,
|
||||
}
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(STRAVA_TOKEN_URL, data=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def exchange_code(code: str) -> dict:
|
||||
"""Exchange an authorization code for tokens. Returns the raw token dict
|
||||
(access_token, refresh_token, expires_at, athlete{...})."""
|
||||
return _token_request({"code": code, "grant_type": "authorization_code"})
|
||||
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> dict:
|
||||
"""Get a fresh access token (and possibly rotated refresh token)."""
|
||||
return _token_request({"refresh_token": refresh_token, "grant_type": "refresh_token"})
|
||||
|
||||
|
||||
def get_valid_access_token(cfg, db) -> str:
|
||||
"""Return a usable access token for `cfg`, refreshing and persisting if the
|
||||
current one is expired or about to expire. `cfg` is a StravaConfig row."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = cfg.expires_at
|
||||
if expires_at is not None and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expires_at is None or expires_at <= now + timedelta(seconds=60):
|
||||
tok = refresh_tokens(decrypt_token(cfg.refresh_token_enc))
|
||||
cfg.access_token_enc = encrypt_token(tok["access_token"])
|
||||
cfg.refresh_token_enc = encrypt_token(tok["refresh_token"])
|
||||
cfg.expires_at = datetime.fromtimestamp(tok["expires_at"], tz=timezone.utc)
|
||||
db.commit()
|
||||
return tok["access_token"]
|
||||
|
||||
return decrypt_token(cfg.access_token_enc)
|
||||
|
||||
|
||||
# ── Activity sync ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _api_get(client: httpx.Client, token: str, path: str, **params):
|
||||
resp = client.get(
|
||||
f"{STRAVA_API_BASE}{path}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params=params,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
raise StravaRateLimited()
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
class StravaRateLimited(Exception):
|
||||
"""Raised when Strava returns HTTP 429 (rate limit exceeded)."""
|
||||
|
||||
|
||||
def _streams_to_points(streams: dict, start_dt: datetime) -> list:
|
||||
"""Convert Strava's key_by_type streams into fit_parser-shaped data points."""
|
||||
time_s = (streams.get("time") or {}).get("data") or []
|
||||
if not time_s:
|
||||
return []
|
||||
latlng = (streams.get("latlng") or {}).get("data") or []
|
||||
altitude = (streams.get("altitude") or {}).get("data") or []
|
||||
heartrate = (streams.get("heartrate") or {}).get("data") or []
|
||||
cadence = (streams.get("cadence") or {}).get("data") or []
|
||||
watts = (streams.get("watts") or {}).get("data") or []
|
||||
velocity = (streams.get("velocity_smooth") or {}).get("data") or []
|
||||
temp = (streams.get("temp") or {}).get("data") or []
|
||||
distance = (streams.get("distance") or {}).get("data") or []
|
||||
|
||||
def at(arr, i):
|
||||
return arr[i] if i < len(arr) else None
|
||||
|
||||
points = []
|
||||
for i, off in enumerate(time_s):
|
||||
ll = at(latlng, i)
|
||||
lat = ll[0] if ll else None
|
||||
lng = ll[1] if ll else None
|
||||
points.append({
|
||||
"timestamp": (start_dt + timedelta(seconds=off)).isoformat(),
|
||||
"latitude": lat, "longitude": lng,
|
||||
"altitude_m": at(altitude, i),
|
||||
"heart_rate": at(heartrate, i),
|
||||
"cadence": at(cadence, i),
|
||||
"speed_ms": at(velocity, i),
|
||||
"power": at(watts, i),
|
||||
"temperature_c": at(temp, i),
|
||||
"distance_m": at(distance, i),
|
||||
})
|
||||
return points
|
||||
|
||||
|
||||
def _build_parsed(summary: dict, points: list) -> dict:
|
||||
"""Assemble a fit_parser-shaped dict from a Strava summary + stream points."""
|
||||
raw_type = (summary.get("sport_type") or summary.get("type") or "workout")
|
||||
sport = STRAVA_SPORT_MAP.get(str(raw_type).lower(), str(raw_type).lower())
|
||||
|
||||
start_str = summary.get("start_date") # UTC ISO, e.g. 2020-01-01T08:00:00Z
|
||||
start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00")) if start_str else None
|
||||
|
||||
coords = [(p["latitude"], p["longitude"]) for p in points if p["latitude"] and p["longitude"]]
|
||||
encoded = polyline_lib.encode(coords) if coords else (summary.get("map") or {}).get("summary_polyline")
|
||||
|
||||
# Elevation loss from the altitude stream (summary only gives gain).
|
||||
alts = [p["altitude_m"] for p in points if p["altitude_m"] is not None]
|
||||
downhill = sum(max(0.0, alts[i-1] - alts[i]) for i in range(1, len(alts))) if alts else None
|
||||
|
||||
return {
|
||||
"name": summary.get("name") or f"{sport.title()} {start_dt.date() if start_dt else ''}".strip(),
|
||||
"sport_type": sport,
|
||||
"start_time": start_dt.isoformat() if start_dt else None,
|
||||
"distance_m": summary.get("distance"),
|
||||
"duration_s": summary.get("elapsed_time"),
|
||||
"moving_time_s": summary.get("moving_time"),
|
||||
"elevation_gain_m": summary.get("total_elevation_gain"),
|
||||
"elevation_loss_m": downhill,
|
||||
"avg_heart_rate": summary.get("average_heartrate"),
|
||||
"max_heart_rate": summary.get("max_heartrate"),
|
||||
"avg_cadence": summary.get("average_cadence"),
|
||||
"avg_power": summary.get("average_watts"),
|
||||
"normalized_power": summary.get("weighted_average_watts"),
|
||||
"avg_speed_ms": summary.get("average_speed"),
|
||||
"max_speed_ms": summary.get("max_speed"),
|
||||
"active_spans": _active_spans(points),
|
||||
"avg_temperature_c": summary.get("average_temp"),
|
||||
"calories": summary.get("calories"),
|
||||
"training_stress_score": None,
|
||||
"vo2max_estimate": None,
|
||||
"polyline": encoded,
|
||||
"bounding_box": _bounding_box(coords),
|
||||
"source_type": "strava",
|
||||
"rejected_reason": None,
|
||||
"data_points": points,
|
||||
"laps": [],
|
||||
}
|
||||
|
||||
|
||||
def sync_strava_activities(cfg, user_id: int, db, lookback_days: int = 30,
|
||||
status_callback=None) -> int:
|
||||
"""List the athlete's activities since the appropriate window, fetch streams
|
||||
for each new one and persist it. Returns the number of activities imported."""
|
||||
import time as _time
|
||||
from app.workers.tasks import persist_activity
|
||||
|
||||
since = cfg.last_sync_at
|
||||
if since is not None and since.tzinfo is None:
|
||||
since = since.replace(tzinfo=timezone.utc)
|
||||
|
||||
if since:
|
||||
after_dt = since - timedelta(days=INCREMENTAL_BUFFER_DAYS)
|
||||
elif lookback_days == -1:
|
||||
after_dt = datetime(2010, 1, 1, tzinfo=timezone.utc)
|
||||
else:
|
||||
after_dt = datetime.now(timezone.utc) - timedelta(days=max(lookback_days, 1))
|
||||
after_epoch = int(after_dt.timestamp())
|
||||
|
||||
token = get_valid_access_token(cfg, db)
|
||||
imported = 0
|
||||
|
||||
with httpx.Client(timeout=60) as client:
|
||||
# 1) Page through activity summaries (newest first within each page).
|
||||
summaries = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = _api_get(client, token, "/athlete/activities",
|
||||
after=after_epoch, page=page, per_page=100)
|
||||
if not batch:
|
||||
break
|
||||
summaries.extend(batch)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
page += 1
|
||||
_time.sleep(0.3)
|
||||
|
||||
total = len(summaries)
|
||||
if status_callback:
|
||||
status_callback(f"Syncing activities: 0/{total} imported")
|
||||
|
||||
# 2) For each, fetch streams and persist. Oldest-first so PRs accrue in order.
|
||||
for idx, summary in enumerate(sorted(summaries, key=lambda s: s.get("start_date") or "")):
|
||||
sid = str(summary.get("id") or "").strip()
|
||||
if not sid:
|
||||
continue
|
||||
|
||||
points = []
|
||||
try:
|
||||
streams = _api_get(
|
||||
client, token, f"/activities/{sid}/streams",
|
||||
keys="time,latlng,altitude,heartrate,cadence,watts,velocity_smooth,temp,distance",
|
||||
key_by_type="true",
|
||||
)
|
||||
start_str = summary.get("start_date")
|
||||
start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00")) if start_str else None
|
||||
if start_dt:
|
||||
points = _streams_to_points(streams, start_dt)
|
||||
except StravaRateLimited:
|
||||
# Out of quota — stop here; the next scheduled sync resumes the rest.
|
||||
if status_callback:
|
||||
status_callback(f"Rate-limited by Strava — imported {imported}, will resume next sync")
|
||||
logger.warning("Strava rate limit hit for user %s after %d imports", user_id, imported)
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch streams for Strava activity %s: %s", sid, exc)
|
||||
# Still persist from the summary (map polyline only, no per-point data).
|
||||
|
||||
parsed = _build_parsed(summary, points)
|
||||
if not parsed.get("start_time"):
|
||||
continue
|
||||
|
||||
try:
|
||||
result = persist_activity(db, user_id, parsed, strava_activity_id=sid)
|
||||
if result.get("status") == "ok":
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist Strava activity %s: %s", sid, exc)
|
||||
db.rollback()
|
||||
|
||||
if status_callback and (idx % 5 == 0 or idx == total - 1):
|
||||
status_callback(f"Syncing activities: {imported}/{total} imported")
|
||||
|
||||
_time.sleep(0.3) # be gentle on the rate limit
|
||||
|
||||
return imported
|
||||
+249
-18
@@ -28,6 +28,12 @@ celery_app.conf.update(
|
||||
# Interval is configurable via GARMIN_SYNC_INTERVAL_MINUTES (default 30 min)
|
||||
"schedule": float(settings.garmin_sync_interval_minutes * 60),
|
||||
},
|
||||
"sync-strava": {
|
||||
"task": "sync_all_strava",
|
||||
# Shares the Garmin cadence setting; Strava's rate limits make a
|
||||
# tighter interval unwise anyway.
|
||||
"schedule": float(settings.garmin_sync_interval_minutes * 60),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -72,24 +78,32 @@ def _apply_garmin_summary(parsed: dict, summary: dict):
|
||||
parsed["moving_time_s"] = summary["moving"]
|
||||
if summary.get("elapsed") is not None:
|
||||
parsed["duration_s"] = summary["elapsed"]
|
||||
dur = parsed.get("duration_s")
|
||||
if parsed.get("distance_m") and dur:
|
||||
parsed["avg_speed_ms"] = parsed["distance_m"] / dur
|
||||
# Recompute average speed over moving (timer) time, not elapsed wall-clock —
|
||||
# otherwise a long mid-activity pause (e.g. a meal stop on a ride) drags the
|
||||
# displayed pace down across the whole break. Falls back to elapsed only when
|
||||
# moving time is unavailable.
|
||||
moving = parsed.get("moving_time_s")
|
||||
denom = moving if (moving and moving > 0) else parsed.get("duration_s")
|
||||
if parsed.get("distance_m") and denom:
|
||||
parsed["avg_speed_ms"] = parsed["distance_m"] / denom
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="process_activity_file")
|
||||
def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
garmin_activity_id: str = None, summary: dict = None):
|
||||
garmin_activity_id: str = None, summary: dict = None,
|
||||
prefer_existing: bool = False):
|
||||
"""Parse a FIT/GPX file. Routes wellness files to health parser.
|
||||
|
||||
`summary` (optional, from Garmin Connect sync) carries Garmin's corrected
|
||||
distance/moving/elapsed values which override the raw FIT figures."""
|
||||
distance/moving/elapsed values which override the raw FIT figures.
|
||||
`prefer_existing` (Strava-export imports) keeps an existing same-time
|
||||
activity instead of adding a duplicate — Garmin data wins."""
|
||||
|
||||
if is_wellness_file(file_path):
|
||||
parse_wellness_fit.delay(file_path, user_id)
|
||||
return {"status": "routed_to_wellness", "file": file_path}
|
||||
|
||||
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
|
||||
from app.services.fit_parser import parse_fit_file, parse_gpx_file, parse_tcx_file, calculate_hr_zones
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
||||
from sqlalchemy import select, func
|
||||
@@ -100,6 +114,8 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
try:
|
||||
if source_type == "fit" or file_path.endswith(".fit"):
|
||||
parsed = parse_fit_file(file_path)
|
||||
elif source_type == "tcx" or file_path.endswith(".tcx"):
|
||||
parsed = parse_tcx_file(file_path)
|
||||
else:
|
||||
parsed = parse_gpx_file(file_path)
|
||||
except Exception as e:
|
||||
@@ -117,29 +133,81 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
return {"status": "skipped", "reason": parsed["rejected_reason"], "file": file_path}
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||
return persist_activity(
|
||||
db, user_id, parsed,
|
||||
source_file=file_path,
|
||||
garmin_activity_id=garmin_activity_id,
|
||||
prefer_existing=prefer_existing,
|
||||
)
|
||||
|
||||
# Deduplicate: same user + sport_type + start_time within ±60s
|
||||
|
||||
def _find_existing_activity(db, user_id: int, sport_type: str, start_time,
|
||||
prefer_existing: bool = False):
|
||||
"""Find an already-imported activity that matches `start_time` (±60s) for this
|
||||
user. Normally also requires the same sport_type. When `prefer_existing` is
|
||||
set (Strava-export imports), the sport_type filter is dropped so a Strava
|
||||
file is treated as a duplicate of an existing activity — typically the Garmin
|
||||
original — even when the two sources labelled the sport differently. This is
|
||||
what makes Garmin data win over Strava re-imports."""
|
||||
from app.models.user import Activity
|
||||
from sqlalchemy import select
|
||||
from datetime import timedelta
|
||||
existing = db.execute(
|
||||
select(Activity).where(
|
||||
|
||||
conds = [
|
||||
Activity.user_id == user_id,
|
||||
Activity.sport_type == parsed["sport_type"],
|
||||
Activity.start_time >= start_time - timedelta(seconds=60),
|
||||
Activity.start_time <= start_time + timedelta(seconds=60),
|
||||
]
|
||||
if not prefer_existing:
|
||||
conds.append(Activity.sport_type == sport_type)
|
||||
return db.execute(select(Activity).where(*conds)).scalars().first()
|
||||
|
||||
|
||||
def persist_activity(db, user_id: int, parsed: dict, *, source_file: str = None,
|
||||
garmin_activity_id: str = None, strava_activity_id: str = None,
|
||||
prefer_existing: bool = False) -> dict:
|
||||
"""Insert a parsed activity (+ data points, laps) and dispatch the
|
||||
PR/route/segment follow-up tasks. Shared by the file-upload path
|
||||
(process_activity_file) and the Strava API sync, so both get identical
|
||||
dedup, HR-zone and downstream behaviour. `parsed` is the fit_parser-shaped
|
||||
dict. `prefer_existing` skips this activity if any existing one matches by
|
||||
time alone (used by Strava imports to keep the Garmin original).
|
||||
Returns {"activity_id", "status"}."""
|
||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap, User as UserModel
|
||||
from app.services.fit_parser import calculate_hr_zones
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime
|
||||
|
||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||
|
||||
# Fast-path dedup for re-syncs: same external id already imported.
|
||||
if strava_activity_id:
|
||||
existing = db.execute(
|
||||
select(Activity).where(Activity.strava_activity_id == str(strava_activity_id))
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return {"activity_id": existing.id, "status": "duplicate"}
|
||||
|
||||
# Deduplicate across sources (see _find_existing_activity). This collapses an
|
||||
# activity present from both Garmin and Strava into the one already stored.
|
||||
existing = _find_existing_activity(
|
||||
db, user_id, parsed["sport_type"], start_time, prefer_existing=prefer_existing,
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
if existing:
|
||||
# Stamp garmin_activity_id if this came from a Garmin Connect sync
|
||||
# so future syncs skip the fast-path dedup and don't re-download.
|
||||
# Stamp the external id so future syncs skip straight to the fast path.
|
||||
stamped = False
|
||||
if garmin_activity_id and not existing.garmin_activity_id:
|
||||
existing.garmin_activity_id = garmin_activity_id
|
||||
stamped = True
|
||||
if strava_activity_id and not existing.strava_activity_id:
|
||||
existing.strava_activity_id = str(strava_activity_id)
|
||||
stamped = True
|
||||
if stamped:
|
||||
db.commit()
|
||||
return {"activity_id": existing.id, "status": "duplicate"}
|
||||
|
||||
# Get user max HR for zone calculation
|
||||
from app.models.user import User as UserModel
|
||||
user_obj = db.execute(
|
||||
select(UserModel).where(UserModel.id == user_id)
|
||||
).scalar_one_or_none()
|
||||
@@ -161,6 +229,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
name=parsed["name"],
|
||||
sport_type=parsed["sport_type"],
|
||||
garmin_activity_id=garmin_activity_id,
|
||||
strava_activity_id=str(strava_activity_id) if strava_activity_id else None,
|
||||
start_time=start_time,
|
||||
distance_m=parsed.get("distance_m"),
|
||||
duration_s=parsed.get("duration_s"),
|
||||
@@ -174,12 +243,13 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
normalized_power=parsed.get("normalized_power"),
|
||||
avg_speed_ms=parsed.get("avg_speed_ms"),
|
||||
max_speed_ms=parsed.get("max_speed_ms"),
|
||||
active_spans=parsed.get("active_spans"),
|
||||
avg_temperature_c=parsed.get("avg_temperature_c"),
|
||||
calories=parsed.get("calories"),
|
||||
training_stress_score=parsed.get("training_stress_score"),
|
||||
polyline=parsed.get("polyline"),
|
||||
bounding_box=parsed.get("bounding_box"),
|
||||
source_file=file_path,
|
||||
source_file=source_file,
|
||||
source_type=parsed.get("source_type"),
|
||||
hr_zones=hr_zones,
|
||||
)
|
||||
@@ -241,6 +311,63 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
return {"activity_id": activity_id, "status": "ok"}
|
||||
|
||||
|
||||
@celery_app.task(name="analyze_strava_export")
|
||||
def analyze_strava_export(file_paths: list, user_id: int):
|
||||
"""Dry-run for a Strava bulk export: parse each activity file and classify it
|
||||
as new / duplicate / unreadable against existing activities, WITHOUT writing
|
||||
anything. Uses the same Garmin-priority dedup the real import uses, so the
|
||||
preview matches what the import would actually do."""
|
||||
from app.services.fit_parser import parse_fit_file, parse_gpx_file, parse_tcx_file
|
||||
from app.core.database import SyncSessionLocal
|
||||
from datetime import datetime
|
||||
|
||||
new = duplicate = unreadable = 0
|
||||
new_samples = []
|
||||
dup_samples = []
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
for fp in file_paths:
|
||||
try:
|
||||
low = fp.lower()
|
||||
if low.endswith(".fit"):
|
||||
parsed = parse_fit_file(fp)
|
||||
elif low.endswith(".tcx"):
|
||||
parsed = parse_tcx_file(fp)
|
||||
else:
|
||||
parsed = parse_gpx_file(fp)
|
||||
start_str = parsed.get("start_time")
|
||||
if not start_str:
|
||||
unreadable += 1
|
||||
continue
|
||||
start_time = datetime.fromisoformat(start_str)
|
||||
except Exception:
|
||||
unreadable += 1
|
||||
continue
|
||||
|
||||
existing = _find_existing_activity(
|
||||
db, user_id, parsed.get("sport_type"), start_time, prefer_existing=True,
|
||||
)
|
||||
label = parsed.get("name") or start_str
|
||||
if existing:
|
||||
duplicate += 1
|
||||
if len(dup_samples) < 8:
|
||||
dup_samples.append(label)
|
||||
else:
|
||||
new += 1
|
||||
if len(new_samples) < 8:
|
||||
new_samples.append(label)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"total": new + duplicate + unreadable,
|
||||
"new": new,
|
||||
"duplicate": duplicate,
|
||||
"unreadable": unreadable,
|
||||
"new_samples": new_samples,
|
||||
"duplicate_samples": dup_samples,
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task(name="parse_wellness_fit")
|
||||
def parse_wellness_fit(file_path: str, user_id: int):
|
||||
"""Parse a Garmin wellness FIT file and upsert into health_metrics."""
|
||||
@@ -403,6 +530,13 @@ def detect_route(activity_id: int, user_id: int):
|
||||
return {"status": "no_match"}
|
||||
|
||||
|
||||
# Personal records are only computed from device-recorded FIT files (Garmin and
|
||||
# other GPS watches/head units). Phone- and Strava-sourced GPX/TCX imports —
|
||||
# especially older ones — suffer GPS "teleporting" that fabricates impossibly
|
||||
# fast splits, so they're excluded from PRs entirely.
|
||||
PR_ELIGIBLE_SOURCE_TYPES = {"fit"}
|
||||
|
||||
|
||||
@celery_app.task(name="compute_personal_records")
|
||||
def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
|
||||
"""Calculate personal records for standard distances from this activity."""
|
||||
@@ -412,6 +546,10 @@ def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Only FIT (watch-recorded) activities are trusted for PRs — see note above.
|
||||
if parsed.get("source_type") not in PR_ELIGIBLE_SOURCE_TYPES:
|
||||
return {"status": "skipped_non_watch", "activity_id": activity_id}
|
||||
|
||||
data_points = parsed.get("data_points", [])
|
||||
total_dist = parsed.get("distance_m", 0) or 0
|
||||
sport = parsed.get("sport_type", "running")
|
||||
@@ -835,6 +973,96 @@ def sync_all_garmin_connect():
|
||||
return {"dispatched": len(user_ids)}
|
||||
|
||||
|
||||
@celery_app.task(name="sync_strava_user")
|
||||
def sync_strava_user(user_id: int):
|
||||
"""Sync activities from Strava for one user via the Strava API."""
|
||||
from app.services.strava_sync import sync_strava_activities
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import StravaConfig
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone
|
||||
|
||||
cancel_key = f"strava_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(StravaConfig).where(StravaConfig.user_id == user_id)
|
||||
).scalar_one_or_none()
|
||||
if not cfg or not cfg.sync_enabled:
|
||||
return {"status": "skipped"}
|
||||
|
||||
lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
|
||||
|
||||
cfg.last_sync_status = "Connecting to Strava..."
|
||||
db.commit()
|
||||
|
||||
def _set_status(text):
|
||||
if _cancelled():
|
||||
raise SyncCancelled()
|
||||
cfg.last_sync_status = text
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
imported = sync_strava_activities(
|
||||
cfg, user_id, db, lookback_days=lookback, status_callback=_set_status,
|
||||
)
|
||||
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"}
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||
msg = str(exc)
|
||||
cfg.last_sync_status = f"Auth error: {msg}" if "401" in msg or "auth" in msg.lower() else f"Error: {msg}"
|
||||
db.commit()
|
||||
return {"status": "error", "error": msg}
|
||||
|
||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||
cfg.last_sync_status = f"OK — {imported} activities imported"
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok", "activities_imported": imported}
|
||||
|
||||
|
||||
@celery_app.task(name="sync_all_strava")
|
||||
def sync_all_strava():
|
||||
"""Beat task: dispatch a per-user Strava sync for all enabled configs."""
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import StravaConfig
|
||||
from sqlalchemy import select
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
configs = db.execute(
|
||||
select(StravaConfig).where(StravaConfig.sync_enabled == True)
|
||||
).scalars().all()
|
||||
user_ids = [c.user_id for c in configs]
|
||||
|
||||
for uid in user_ids:
|
||||
sync_strava_user.delay(uid)
|
||||
|
||||
return {"dispatched": len(user_ids)}
|
||||
|
||||
|
||||
@celery_app.task(name="recalculate_hr_zones_for_user")
|
||||
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
|
||||
"""Recalculate hr_zones for all of a user's activities using a new max HR."""
|
||||
@@ -971,8 +1199,9 @@ def backfill_indoor_distances(user_id: int):
|
||||
@celery_app.task(name="recompute_personal_records_all")
|
||||
def recompute_personal_records_all(user_id: int):
|
||||
"""Wipe and rebuild all personal records from stored activity data, excluding
|
||||
indoor (no-GPS) runs whose distance is unreliable. Fixes records polluted by
|
||||
treadmill over-measurement and any duplicate current-record rows."""
|
||||
indoor (no-GPS) runs and non-watch (GPX/TCX) imports whose distance is
|
||||
unreliable. Fixes records polluted by treadmill over-measurement, GPS
|
||||
teleporting in old phone/Strava imports, and any duplicate current-record rows."""
|
||||
from app.services.route_matcher import compute_best_splits, STANDARD_DISTANCES
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Activity, ActivityDataPoint, PersonalRecord
|
||||
@@ -992,6 +1221,8 @@ def recompute_personal_records_all(user_id: int):
|
||||
|
||||
best = {} # (sport, dist_m) -> {dur, aid, at, label}
|
||||
for a in acts:
|
||||
if a.source_type not in PR_ELIGIBLE_SOURCE_TYPES:
|
||||
continue # only watch-recorded FIT files count toward PRs
|
||||
if a.sport_type == "running" and a.polyline is None:
|
||||
continue # indoor/treadmill — unreliable distance
|
||||
rows = db.execute(
|
||||
|
||||
@@ -58,6 +58,9 @@ services:
|
||||
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
||||
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
||||
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
||||
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||
BASE_URL: ${BASE_URL:-https://milevault.jarrett.eu}
|
||||
FILE_STORE_PATH: /data/files
|
||||
ENVIRONMENT: production
|
||||
volumes:
|
||||
@@ -82,6 +85,8 @@ services:
|
||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
||||
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||
FILE_STORE_PATH: /data/files
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
@@ -100,6 +105,8 @@ services:
|
||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
||||
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||
FILE_STORE_PATH: /data/files
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
|
||||
+12
-10
@@ -10,7 +10,7 @@ services:
|
||||
POSTGRES_USER: ${DB_USER:-milevault}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
- ./db_data:/var/lib/postgresql/data
|
||||
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
|
||||
interval: 10s
|
||||
@@ -48,10 +48,13 @@ services:
|
||||
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
||||
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
||||
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
||||
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||
BASE_URL: ${BASE_URL:-https://milevault.jarrett.eu}
|
||||
FILE_STORE_PATH: /data/files
|
||||
ENVIRONMENT: ${ENVIRONMENT:-production}
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
- ./file_data:/data/files
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -74,9 +77,11 @@ services:
|
||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||
FILE_STORE_PATH: /data/files
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
- ./file_data:/data/files
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -94,9 +99,11 @@ services:
|
||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||
FILE_STORE_PATH: /data/files
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
- ./file_data:/data/files
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -124,8 +131,3 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
redis_data:
|
||||
file_data:
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
|
||||
@@ -7,6 +7,14 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Never let the browser serve a stale SPA shell: it references hashed asset
|
||||
# filenames, so a cached index.html strands users on an old build after a
|
||||
# deploy (Edge heuristically caches it when no Cache-Control is set). Force
|
||||
# revalidation — a cheap 304 when unchanged — so new deploys are picked up.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
Generated
+545
-1150
File diff suppressed because it is too large
Load Diff
@@ -11,23 +11,21 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"axios": "^1.7.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-grid-layout": "^1.5.3",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"recharts": "^2.12.7",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"vite": "^5.2.13"
|
||||
"vite": "^8.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import LoginPage from './pages/LoginPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import ActivitiesPage from './pages/ActivitiesPage'
|
||||
import ActivityDetailPage from './pages/ActivityDetailPage'
|
||||
import SummaryPage from './pages/SummaryPage'
|
||||
import HealthPage from './pages/HealthPage'
|
||||
import RoutesPage from './pages/RoutesPage'
|
||||
import RecordsPage from './pages/RecordsPage'
|
||||
@@ -33,8 +34,10 @@ export default function App() {
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="activities" element={<ActivitiesPage />} />
|
||||
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
||||
<Route path="summary" element={<SummaryPage />} />
|
||||
<Route path="health" element={<HealthPage />} />
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="routes/:routeId" element={<RoutesPage />} />
|
||||
<Route path="records" element={<RecordsPage />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { sportColor } from '../../utils/format'
|
||||
import { projectToTrack } from '../../utils/track'
|
||||
import { useResolvedTile } from '../../hooks/useMapSettings'
|
||||
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
@@ -10,24 +11,9 @@ L.Icon.Default.mergeOptions({
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
})
|
||||
|
||||
const TILE_LAYERS = {
|
||||
dark: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
},
|
||||
street: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
},
|
||||
satellite: {
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© <a href="https://www.esri.com/">Esri</a>',
|
||||
},
|
||||
}
|
||||
|
||||
// Tile options tuned for smoother panning/zooming: keep a larger off-screen
|
||||
// buffer of tiles and don't defer loads until the map is idle.
|
||||
const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false }
|
||||
const TILE_OPTS = { keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false }
|
||||
|
||||
// Slow → fast colour ramp for speed-coloured routes (red → purple).
|
||||
export const SPEED_STOPS = ['#ef4444', '#f97316', '#22c55e', '#3b82f6', '#a855f7']
|
||||
@@ -145,7 +131,8 @@ function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef
|
||||
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
|
||||
}
|
||||
|
||||
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'street', colorMode = 'speed', onMapClick }) {
|
||||
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, satellite = false, colorMode = 'speed', onMapClick }) {
|
||||
const tile = useResolvedTile(satellite)
|
||||
const mapRef = useRef(null)
|
||||
const mapInstanceRef = useRef(null)
|
||||
const markerRef = useRef(null)
|
||||
@@ -167,10 +154,6 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
||||
preferCanvas: true,
|
||||
})
|
||||
|
||||
const tile = TILE_LAYERS.street
|
||||
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
|
||||
.addTo(mapInstanceRef.current)
|
||||
|
||||
mapInstanceRef.current.on('click', (e) => {
|
||||
if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng })
|
||||
})
|
||||
@@ -209,11 +192,11 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street
|
||||
if (tileLayerRef.current) tileLayerRef.current.remove()
|
||||
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
|
||||
.addTo(mapInstanceRef.current)
|
||||
}, [mapType])
|
||||
tileLayerRef.current = L.tileLayer(tile.url, {
|
||||
attribution: tile.attribution, maxZoom: tile.maxZoom, subdomains: tile.subdomains, ...TILE_OPTS,
|
||||
}).addTo(mapInstanceRef.current)
|
||||
}, [tile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
|
||||
@@ -2,9 +2,28 @@ import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCade
|
||||
|
||||
const RUNNING_TYPES = new Set(['running', 'hiking', 'walking'])
|
||||
|
||||
export default function LapTable({ laps, sportType, lapBests }) {
|
||||
// Most common lap distance (rounded to 100 m), used to tell "whole" laps from
|
||||
// the short trailing fragment that auto-lapping leaves at the end of a run.
|
||||
function modalDistance(laps) {
|
||||
const counts = {}
|
||||
for (const l of laps) {
|
||||
if (l.distance_m == null) continue
|
||||
const key = Math.round(l.distance_m / 100) * 100
|
||||
counts[key] = (counts[key] || 0) + 1
|
||||
}
|
||||
let best = null, bestN = 0
|
||||
for (const [k, n] of Object.entries(counts)) {
|
||||
if (n > bestN) { bestN = n; best = Number(k) }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export default function LapTable({ laps, sportType, lapBests, records, unit = 'km' }) {
|
||||
const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
|
||||
const hasBests = lapBests && Object.keys(lapBests).length > 0
|
||||
const modal = modalDistance(laps)
|
||||
const prs = records || []
|
||||
const showLegend = hasBests || prs.length > 0
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -23,25 +42,46 @@ export default function LapTable({ laps, sportType, lapBests }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{laps.map((lap) => {
|
||||
const best = hasBests ? lapBests[String(lap.lap_number)] : null
|
||||
// Only "whole" laps (within 10% of the typical lap distance) are
|
||||
// eligible for awards — a 10 m trailing fragment isn't a real lap.
|
||||
const isWhole = lap.distance_m != null && modal != null && lap.distance_m >= modal * 0.9
|
||||
const best = hasBests && isWhole ? lapBests[String(lap.lap_number)] : null
|
||||
const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null
|
||||
const isBest = delta != null && delta <= 0.5
|
||||
const isLapBest = delta != null && delta <= 0.5
|
||||
// A personal record set on this lap: a standard-distance PR whose
|
||||
// distance and time line up with this lap.
|
||||
const isPR = isWhole && lap.duration_s != null && prs.some(r =>
|
||||
r.distance_m != null && Math.abs(lap.distance_m - r.distance_m) <= r.distance_m * 0.05 &&
|
||||
Math.abs(lap.duration_s - r.duration_s) <= 2
|
||||
)
|
||||
return (
|
||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
||||
<td className={`py-2 text-right ${isBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
|
||||
<tr
|
||||
key={lap.lap_number}
|
||||
className={`border-b transition-colors ${
|
||||
isPR
|
||||
? 'bg-yellow-500/10 border-yellow-500/30 hover:bg-yellow-500/15'
|
||||
: 'border-gray-800/50 hover:bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 text-gray-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{lap.lap_number}
|
||||
{isPR && <span title="Personal best">🥇</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m, unit)}</td>
|
||||
<td className={`py-2 text-right ${isPR || isLapBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
|
||||
{hasBests && (
|
||||
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
|
||||
)}
|
||||
{hasBests && (
|
||||
<td className={`py-2 text-right font-mono ${
|
||||
delta == null ? 'text-gray-700' : isBest ? 'text-yellow-400' : delta < 0 ? 'text-green-400' : 'text-red-400'
|
||||
delta == null ? 'text-gray-700' : isLapBest ? 'text-yellow-400' : delta < 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
{delta == null ? '--' : isBest ? '🏆' : `${delta > 0 ? '+' : '−'}${formatDuration(Math.abs(delta))}`}
|
||||
{delta == null ? '--' : isLapBest ? <span title="Fastest on this route">🏆</span> : `${delta > 0 ? '+' : '−'}${formatDuration(Math.abs(delta))}`}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType, unit)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||
</td>
|
||||
@@ -58,6 +98,12 @@ export default function LapTable({ laps, sportType, lapBests }) {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{showLegend && (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-3 text-xs text-gray-500">
|
||||
{prs.length > 0 && <span>🥇 Personal best for the distance</span>}
|
||||
{hasBests && <span>🏆 Fastest time for this lap on the route</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { formatPace, formatCadence } from '../../utils/format'
|
||||
import { formatPace, formatCadence, formatDistance, formatElevation, distanceUnitLabel } from '../../utils/format'
|
||||
|
||||
// Running cadence colour bands (steps per minute). Cadence is stored halved for
|
||||
// running, so spm = stored × 2.
|
||||
@@ -51,22 +51,22 @@ function buildChartData(dataPoints, activeMetrics, useTimeAxis) {
|
||||
})
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, useTimeAxis }) => {
|
||||
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, useTimeAxis, unit = 'km' }) => {
|
||||
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">{useTimeAxis ? fmtSeconds(label) : `${(label / 1000).toFixed(2)} km`}</p>
|
||||
<p className="text-gray-400 mb-1">{useTimeAxis ? fmtSeconds(label) : formatDistance(label, unit)}</p>
|
||||
{payload.map(entry => {
|
||||
const metric = metrics.find(m => m.key === entry.dataKey)
|
||||
if (!metric || entry.value == null) return null
|
||||
let display = entry.value.toFixed(1)
|
||||
if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType)
|
||||
if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType, unit)
|
||||
else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm`
|
||||
else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType)
|
||||
else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W`
|
||||
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
|
||||
else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m`
|
||||
else if (entry.dataKey === 'altitude_m') display = formatElevation(entry.value, unit)
|
||||
return (
|
||||
<div key={entry.dataKey} className="flex items-center gap-2">
|
||||
<span style={{ color: entry.color }}>●</span>
|
||||
@@ -79,7 +79,7 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, us
|
||||
)
|
||||
}
|
||||
|
||||
export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) {
|
||||
export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType, unit = 'km' }) {
|
||||
// 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.
|
||||
@@ -142,7 +142,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
||||
dataKey="x"
|
||||
type="number"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={v => useTimeAxis ? fmtSeconds(v) : `${(v / 1000).toFixed(1)}`}
|
||||
tickFormatter={v => useTimeAxis ? fmtSeconds(v) : `${(unit === 'mi' ? v / 1609.344 : v / 1000).toFixed(1)}`}
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
@@ -157,8 +157,8 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
||||
tickFormatter={v => {
|
||||
if (metric.key === 'speed_ms') {
|
||||
if (v <= 0 || v > 25) return ''
|
||||
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
||||
const spm = 1000 / v
|
||||
if (sportType === 'cycling') return `${(unit === 'mi' ? v * 2.2369363 : v * 3.6).toFixed(0)}`
|
||||
const spm = (unit === 'mi' ? 1609.344 : 1000) / v
|
||||
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
|
||||
}
|
||||
if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1))
|
||||
@@ -166,7 +166,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} useTimeAxis={useTimeAxis} />}
|
||||
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} useTimeAxis={useTimeAxis} unit={unit} />}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{metric.key === 'cadence' && sportType === 'running' ? (
|
||||
@@ -191,7 +191,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className="text-xs text-gray-600 text-center">{useTimeAxis ? 'Elapsed time (mm:ss)' : 'Distance (km)'}</p>
|
||||
<p className="text-xs text-gray-600 text-center">{useTimeAxis ? 'Elapsed time (mm:ss)' : `Distance (${distanceUnitLabel(unit)})`}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../../utils/api'
|
||||
import { formatDuration, formatDistance } from '../../utils/format'
|
||||
import { useUnit } from '../../hooks/useUnits'
|
||||
|
||||
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
|
||||
const PLACE_MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||
@@ -72,7 +73,10 @@ function Leaderboard({ segmentId, activityId }) {
|
||||
|
||||
export default function SegmentsPanel({ segments, activityId }) {
|
||||
const qc = useQueryClient()
|
||||
const unit = useUnit()
|
||||
const [open, setOpen] = useState(null)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!confirm('Delete this segment?')) return
|
||||
@@ -80,6 +84,20 @@ export default function SegmentsPanel({ segments, activityId }) {
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
|
||||
const startRename = (seg) => {
|
||||
setEditingId(seg.segment_id)
|
||||
setEditName(seg.name)
|
||||
}
|
||||
const saveRename = async (id) => {
|
||||
const next = editName.trim()
|
||||
if (next) {
|
||||
await api.patch(`/segments/${id}`, { name: next })
|
||||
qc.invalidateQueries({ queryKey: ['activity-segments', activityId] })
|
||||
qc.invalidateQueries({ queryKey: ['segment', id] })
|
||||
}
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -103,14 +121,28 @@ export default function SegmentsPanel({ segments, activityId }) {
|
||||
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
|
||||
>
|
||||
<td className="py-2">
|
||||
{editingId === seg.segment_id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveRename(seg.segment_id)
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
onBlur={() => saveRename(seg.segment_id)}
|
||||
className="bg-gray-800 text-white rounded px-2 py-0.5 border border-gray-600 focus:border-blue-500 focus:outline-none text-sm"
|
||||
/>
|
||||
) : (
|
||||
<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 text-xs">{formatDistance(seg.distance_m)}</span>
|
||||
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m, unit)}</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
||||
{formatDuration(seg.duration_s)}
|
||||
@@ -125,7 +157,8 @@ export default function SegmentsPanel({ segments, activityId }) {
|
||||
? <span className="text-gray-500">+{formatDuration(delta)}</span>
|
||||
: <span className="text-gray-700">--</span>}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<td className="py-2 text-right whitespace-nowrap">
|
||||
<button onClick={() => startRename(seg)} className="text-gray-700 hover:text-white text-xs mr-2" title="Rename segment">✎</button>
|
||||
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
BarChart, Bar, Cell, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceArea,
|
||||
} from 'recharts'
|
||||
import { format } from 'date-fns'
|
||||
import SportIcon from '../ui/SportIcon'
|
||||
import { sportColor } from '../../utils/format'
|
||||
import {
|
||||
BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType,
|
||||
} from '../../utils/bodyBattery'
|
||||
|
||||
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, color: '#fff' }
|
||||
|
||||
// Activity time spans are drawn as a solid coloured band in a reserved strip
|
||||
// *below* the battery bars (the negative Y region) so they don't obscure data.
|
||||
const ACTIVITY_BAND_BOTTOM = -18
|
||||
|
||||
function ActivityRefLabel({ viewBox, sport, size = 14 }) {
|
||||
if (!viewBox) return null
|
||||
const { x, y, width = 0, height = 0 } = viewBox
|
||||
return (
|
||||
<SportIcon sport={sport} size={size} color="#fff"
|
||||
x={x + width / 2 - size / 2} y={y + height / 2 - size / 2}
|
||||
style={{ pointerEvents: 'none' }} />
|
||||
)
|
||||
}
|
||||
|
||||
// A small ⏸ glyph drawn over the gap between two active spans, marking where
|
||||
// the recording was paused mid-activity (e.g. a long lunch break on a ride).
|
||||
function PauseRefLabel({ viewBox }) {
|
||||
if (!viewBox) return null
|
||||
const { x, y, width = 0, height = 0 } = viewBox
|
||||
const cx = x + width / 2, cy = y + height / 2
|
||||
const barW = 2, barH = 8, gap = 1.5
|
||||
return (
|
||||
<g style={{ pointerEvents: 'none' }}>
|
||||
<rect x={cx - gap - barW} y={cy - barH / 2} width={barW} height={barH} fill="#fff" opacity={0.85} />
|
||||
<rect x={cx + gap} y={cy - barH / 2} width={barW} height={barH} fill="#fff" opacity={0.85} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
// Shared Body Battery bar chart used by both the Health page and the Dashboard
|
||||
// widget. Renders the stats header, the coloured battery bars, an activity band
|
||||
// strip below them (splitting on mid-activity pauses via `active_spans`), and a
|
||||
// state legend. The caller supplies the outer frame (panel / Card). Props tune
|
||||
// the few cosmetic differences between the two placements.
|
||||
export default function BodyBatteryChart({
|
||||
bb, hires, sleepStart, sleepEnd, activities,
|
||||
yTicks = [0, 25, 50, 75, 100], yAxisWidth = 28, leftMargin = 28,
|
||||
fill = false, height = 100, minHeight = 80, iconSize = 14,
|
||||
emptyText = null,
|
||||
}) {
|
||||
const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ t: ts, level }))
|
||||
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
|
||||
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
|
||||
const data = raw.map((d, i) => ({
|
||||
...d,
|
||||
type: inferBBType(d.t, d.level, i > 0 ? raw[i - 1].level : null, sleepStartMs, sleepEndMs),
|
||||
}))
|
||||
|
||||
const charged = bb?.charged, drained = bb?.drained, end_level = bb?.end_level
|
||||
const peak = data.length ? Math.max(...data.map(d => d.level)) : end_level
|
||||
const presentTypes = [...new Set(data.map(d => d.type))]
|
||||
const hasGraph = data.length >= 2
|
||||
|
||||
// Nothing at all to show.
|
||||
if (!hasGraph && peak == null && end_level == null) return null
|
||||
|
||||
// Only activities overlapping the battery samples for this day get a band.
|
||||
const dayStart = data.length ? data[0].t : null
|
||||
const dayEnd = data.length ? data[data.length - 1].t : null
|
||||
const dayActivities = (activities || []).filter(a => {
|
||||
if (dayStart == null) return false
|
||||
const start = new Date(a.start_time).getTime()
|
||||
const end = a.duration_s ? start + a.duration_s * 1000 : start
|
||||
return end >= dayStart && start <= dayEnd
|
||||
})
|
||||
const hasActivities = dayActivities.length > 0
|
||||
|
||||
// The X axis is categorical (band scale), so overlays must snap to a sample
|
||||
// that exists in the data.
|
||||
const nearestT = (ms) => {
|
||||
let best = null, bd = Infinity
|
||||
for (const d of data) { const dd = Math.abs(d.t - ms); if (dd < bd) { bd = dd; best = d.t } }
|
||||
return best
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
{peak != null && (
|
||||
<span className="text-3xl font-bold" style={{ color: bbLevelColor(peak) }}>{Math.round(peak)}</span>
|
||||
)}
|
||||
{charged != null && <span className="text-sm font-semibold text-green-400">+{charged}</span>}
|
||||
{drained != null && <span className="text-sm font-semibold text-orange-400">-{drained}</span>}
|
||||
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
|
||||
</div>
|
||||
|
||||
{hasGraph ? (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 mt-2">
|
||||
<ResponsiveContainer width="100%" height={fill ? '100%' : height} minHeight={minHeight}>
|
||||
<BarChart data={data} margin={{ top: 2, right: 4, bottom: 0, left: leftMargin }} barCategoryGap={0}>
|
||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
interval={Math.max(1, Math.floor(data.length / 6))} />
|
||||
<YAxis domain={[hasActivities ? ACTIVITY_BAND_BOTTOM : 0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }}
|
||||
axisLine={false} tickLine={false} width={yAxisWidth} ticks={yTicks} />
|
||||
<Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
formatter={v => [`${Math.round(v)}%`, 'Battery']} />
|
||||
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
||||
{data.map((d, i) => <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />)}
|
||||
</Bar>
|
||||
{dayActivities.flatMap(a => {
|
||||
const color = sportColor(a.sport_type)
|
||||
const start = new Date(a.start_time).getTime()
|
||||
const fullEnd = a.duration_s ? start + a.duration_s * 1000 : start
|
||||
// active_spans (set only when a long pause splits the
|
||||
// recording) draws one band per moving span with the pause
|
||||
// shown as a gap; otherwise one continuous band.
|
||||
const spans = (a.active_spans && a.active_spans.length > 1)
|
||||
? a.active_spans
|
||||
: [[start, fullEnd]]
|
||||
// Carry the sport icon on the longest span only.
|
||||
let labelIdx = 0, labelLen = -1
|
||||
spans.forEach((s, i) => { const l = s[1] - s[0]; if (l > labelLen) { labelLen = l; labelIdx = i } })
|
||||
const els = []
|
||||
spans.forEach((s, i) => {
|
||||
const x1 = nearestT(s[0]), x2 = nearestT(s[1])
|
||||
if (x1 != null && x2 != null) {
|
||||
els.push(
|
||||
<ReferenceArea key={`area-${a.id}-${i}`} x1={x1} x2={x2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
||||
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
|
||||
label={i === labelIdx ? <ActivityRefLabel sport={a.sport_type} size={iconSize} /> : undefined} />
|
||||
)
|
||||
}
|
||||
// Mark the paused stretch before the next span.
|
||||
if (i < spans.length - 1) {
|
||||
const g1 = nearestT(s[1]), g2 = nearestT(spans[i + 1][0])
|
||||
if (g1 != null && g2 != null && g1 !== g2) {
|
||||
els.push(
|
||||
<ReferenceArea key={`pause-${a.id}-${i}`} x1={g1} x2={g2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
||||
fill={color} fillOpacity={0.12} stroke={color} strokeOpacity={0.7} strokeDasharray="3 3"
|
||||
label={<PauseRefLabel />} />
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
return els
|
||||
})}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{presentTypes.map(type => (
|
||||
<div key={type} className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
|
||||
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
emptyText ? <p className="text-xs text-gray-600 mt-3">{emptyText}</p> : null
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState, useRef } from 'react'
|
||||
|
||||
// Proper sleep hypnogram: 4 horizontal lanes (Awake/REM/Light/Deep), time on X axis.
|
||||
const SLEEP_LANE_ORDER = [1, 4, 2, 3] // top→bottom: awake, rem, light, deep
|
||||
const SLEEP_STAGE_COLOR = { 0: '#6b7280', 1: '#eab308', 2: '#a78bfa', 3: '#6366f1', 4: '#7c3aed' }
|
||||
const SLEEP_STAGE_LABEL = { 1: 'Awake', 2: 'Light', 3: 'Deep', 4: 'REM' }
|
||||
const LANE_H = 15
|
||||
|
||||
const fmtClock = (ms) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
export default function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
|
||||
const wrapRef = useRef(null)
|
||||
const [tip, setTip] = useState(null)
|
||||
if (!sleepStart || !sleepEnd || !stages?.length) return null
|
||||
const startMs = new Date(sleepStart).getTime()
|
||||
const endMs = new Date(sleepEnd).getTime()
|
||||
const windowMs = endMs - startMs
|
||||
if (windowMs <= 0) return null
|
||||
|
||||
// Build segments per lane (keep each segment's real start/end for the tooltip)
|
||||
const segsByLane = {}
|
||||
SLEEP_LANE_ORDER.forEach(lv => { segsByLane[lv] = [] })
|
||||
stages.forEach(([tsMs, level], i) => {
|
||||
if (!(level in segsByLane)) return
|
||||
const nextTs = i + 1 < stages.length ? stages[i + 1][0] : endMs
|
||||
const left = Math.max(0, (tsMs - startMs) / windowMs * 100)
|
||||
const right = Math.min(100, (nextTs - startMs) / windowMs * 100)
|
||||
const w = right - left
|
||||
if (w > 0) segsByLane[level].push({ left, w, level, startMs: tsMs, endMs: nextTs })
|
||||
})
|
||||
|
||||
const showTip = (seg, e) => {
|
||||
const rect = wrapRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
setTip({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
level: seg.level,
|
||||
range: `${fmtClock(seg.startMs)}–${fmtClock(seg.endMs)}`,
|
||||
mins: Math.max(1, Math.round((seg.endMs - seg.startMs) / 60000)),
|
||||
})
|
||||
}
|
||||
|
||||
// Hour ticks
|
||||
const sh = new Date(startMs); sh.setMinutes(0, 0, 0); sh.setHours(sh.getHours() + 1)
|
||||
const ticks = []
|
||||
for (let t = sh.getTime(); t < endMs; t += 3600000) {
|
||||
const pct = (t - startMs) / windowMs * 100
|
||||
if (pct >= 0 && pct <= 100)
|
||||
ticks.push({ pct, label: new Date(t).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<div ref={wrapRef} className="relative" onMouseLeave={() => setTip(null)}>
|
||||
<div className="space-y-px">
|
||||
{SLEEP_LANE_ORDER.map(level => (
|
||||
<div key={level} className="relative flex items-center">
|
||||
<span className="absolute right-full pr-1.5 text-gray-500 whitespace-nowrap select-none"
|
||||
style={{ fontSize: 10 }}>
|
||||
{SLEEP_STAGE_LABEL[level]}
|
||||
</span>
|
||||
<div className="relative flex-1 rounded-sm overflow-hidden bg-gray-800/50" style={{ height: LANE_H }}>
|
||||
{segsByLane[level].map((seg, i) => (
|
||||
<div key={i} className="absolute top-0 h-full cursor-pointer"
|
||||
style={{ left: `${seg.left}%`, width: `${seg.w}%`, backgroundColor: SLEEP_STAGE_COLOR[level] }}
|
||||
onMouseEnter={(e) => showTip(seg, e)}
|
||||
onMouseMove={(e) => showTip(seg, e)} />
|
||||
))}
|
||||
{ticks.map((t, i) => (
|
||||
<div key={i} className="absolute top-0 bottom-0 w-px bg-black/20 pointer-events-none"
|
||||
style={{ left: `${t.pct}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tip && (
|
||||
<div className="absolute z-20 pointer-events-none px-2 py-1 rounded-md bg-gray-900/95 border border-gray-700 shadow-lg whitespace-nowrap flex items-center gap-1.5"
|
||||
style={{ left: tip.x, top: tip.y - 10, transform: 'translate(-50%, -100%)', fontSize: 11 }}>
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ backgroundColor: SLEEP_STAGE_COLOR[tip.level] }} />
|
||||
<span className="text-white font-medium">{SLEEP_STAGE_LABEL[tip.level]}</span>
|
||||
<span className="text-gray-400">{tip.range}</span>
|
||||
<span className="text-gray-500">· {tip.mins}m</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-4 mt-1 ml-0">
|
||||
<span className="absolute left-0 text-gray-500" style={{ fontSize: 10 }}>
|
||||
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{ticks.map((t, i) => (
|
||||
<span key={i} className="absolute text-gray-600"
|
||||
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)', fontSize: 10 }}>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="absolute right-0 text-gray-500" style={{ fontSize: 10 }}>
|
||||
{new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Coloured pill for a Garmin HRV status (balanced / unbalanced / low / poor).
|
||||
// Shared by the Health page and the Dashboard HRV widget so the palette stays
|
||||
// consistent across the app.
|
||||
const HRV_PALETTE = {
|
||||
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
||||
unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30',
|
||||
low: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
||||
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||
}
|
||||
|
||||
export default function HrvBadge({ status }) {
|
||||
if (!status) return null
|
||||
const cls = HRV_PALETTE[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30'
|
||||
return <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../hooks/useAuth'
|
||||
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
||||
import { useHydrateMapSettings } from '../../hooks/useMapSettings'
|
||||
import UnitToggle from './UnitToggle'
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: 'Dashboard', icon: '📊', exact: true },
|
||||
{ to: '/activities', label: 'Activities', icon: '🏃' },
|
||||
{ to: '/health', label: 'Health', icon: '❤️' },
|
||||
{ to: '/routes', label: 'Routes', icon: '🗺️' },
|
||||
{ to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true },
|
||||
{ to: '/activities', label: 'Activities', icon: '🏃', mobilePrimary: true },
|
||||
{ to: '/summary', label: 'Summary', icon: '📈' },
|
||||
{ to: '/health', label: 'Health', icon: '❤️', mobilePrimary: true },
|
||||
{ to: '/routes', label: 'Routes', icon: '🗺️', mobilePrimary: true },
|
||||
{ to: '/records', label: 'Records', icon: '🏆' },
|
||||
{ to: '/upload', label: 'Import', icon: '⬆️' },
|
||||
{ to: '/profile', label: 'Profile', icon: '⚙️' },
|
||||
@@ -17,14 +20,22 @@ const nav = [
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { inProgress, status, startPolling, stopPolling } = useSyncStore()
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
|
||||
// Load the user's saved map tile preference from the server into the store.
|
||||
useHydrateMapSettings()
|
||||
|
||||
useEffect(() => {
|
||||
startPolling()
|
||||
return () => stopPolling()
|
||||
}, [])
|
||||
|
||||
// Close the mobile "More" sheet on navigation
|
||||
useEffect(() => { setMoreOpen(false) }, [location.pathname])
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(c => {
|
||||
const next = !c
|
||||
@@ -39,10 +50,28 @@ export default function Layout() {
|
||||
}
|
||||
|
||||
const role = user?.is_admin ? 'Administrator' : 'Member'
|
||||
const visibleNav = nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin)
|
||||
const primaryNav = visibleNav.filter(i => i.mobilePrimary)
|
||||
const moreNav = visibleNav.filter(i => !i.mobilePrimary)
|
||||
const moreActive = moreNav.some(i => location.pathname.startsWith(i.to))
|
||||
|
||||
const syncProgress = (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-xs text-blue-400">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Garmin sync
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full transition-all duration-700"
|
||||
style={{ width: `${syncProgressPct(status)}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-gray-950">
|
||||
<aside className={`${collapsed ? 'w-16' : 'w-56'} flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col transition-[width] duration-200`}>
|
||||
<div className="flex flex-col md:flex-row h-dvh overflow-hidden bg-gray-950">
|
||||
<aside className={`${collapsed ? 'w-16' : 'w-56'} hidden md:flex flex-shrink-0 bg-gray-900 border-r border-gray-800 flex-col transition-[width] duration-200`}>
|
||||
<div className={`flex items-center border-b border-gray-800 px-3 py-5 ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
||||
{!collapsed && (
|
||||
<h1 className="text-lg font-bold text-white tracking-tight">
|
||||
@@ -57,7 +86,7 @@ export default function Layout() {
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
{nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ to, label, icon, exact }) => (
|
||||
{visibleNav.map(({ to, label, icon, exact }) => (
|
||||
<NavLink key={to} to={to} end={exact} title={collapsed ? label : undefined}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 py-2.5 text-sm transition-colors ${collapsed ? 'justify-center px-0' : 'px-4'} ${
|
||||
@@ -73,16 +102,8 @@ export default function Layout() {
|
||||
</nav>
|
||||
|
||||
{inProgress && !collapsed && (
|
||||
<div className="px-4 py-3 border-t border-gray-800 space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-xs text-blue-400">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Garmin sync
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full transition-all duration-700"
|
||||
style={{ width: `${syncProgressPct(status)}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
|
||||
<div className="px-4 py-3 border-t border-gray-800">
|
||||
{syncProgress}
|
||||
</div>
|
||||
)}
|
||||
{inProgress && collapsed && (
|
||||
@@ -91,6 +112,14 @@ export default function Layout() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Distance-unit toggle (km / mi) */}
|
||||
{!collapsed && (
|
||||
<div className="flex items-center justify-between border-t border-gray-800 px-4 py-3">
|
||||
<span className="text-xs text-gray-500">Units</span>
|
||||
<UnitToggle />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logged-in user + privilege level */}
|
||||
<div className="border-t border-gray-800 p-3">
|
||||
{user ? (
|
||||
@@ -125,9 +154,98 @@ export default function Layout() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile top header */}
|
||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-gray-900 border-b border-gray-800">
|
||||
<h1 className="text-lg font-bold text-white tracking-tight">
|
||||
<span className="text-blue-400">Mile</span>Vault
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<UnitToggle />
|
||||
{inProgress && (
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400 animate-pulse"
|
||||
title={`Garmin sync: ${status || 'starting…'}`} />
|
||||
)}
|
||||
{user && (
|
||||
<span className="w-8 h-8 rounded-full bg-blue-600/20 text-blue-300 flex items-center justify-center text-sm font-semibold uppercase">
|
||||
{user.username?.[0] || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Mobile bottom tab bar — z above Leaflet controls (~1000) */}
|
||||
<nav className="md:hidden relative z-[1100] flex items-stretch bg-gray-900 border-t border-gray-800 pb-[env(safe-area-inset-bottom)]">
|
||||
{primaryNav.map(({ to, label, icon, exact }) => (
|
||||
<NavLink key={to} to={to} end={exact}
|
||||
className={({ isActive }) =>
|
||||
`flex-1 flex flex-col items-center gap-0.5 py-2 text-[10px] transition-colors ${
|
||||
isActive ? 'text-blue-400' : 'text-gray-500'
|
||||
}`
|
||||
}>
|
||||
<span className="text-lg leading-none">{icon}</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<button onClick={() => setMoreOpen(true)}
|
||||
className={`relative flex-1 flex flex-col items-center gap-0.5 py-2 text-[10px] transition-colors ${
|
||||
moreActive ? 'text-blue-400' : 'text-gray-500'
|
||||
}`}>
|
||||
<span className="text-lg leading-none">☰</span>
|
||||
More
|
||||
{inProgress && (
|
||||
<span className="absolute top-1.5 right-1/2 translate-x-4 inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile "More" slide-up sheet */}
|
||||
{moreOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-[1200]">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => setMoreOpen(false)} />
|
||||
<div className="absolute bottom-0 inset-x-0 bg-gray-900 rounded-t-2xl border-t border-gray-800 p-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
|
||||
<div className="flex justify-center mb-3">
|
||||
<span className="w-10 h-1 rounded-full bg-gray-700" />
|
||||
</div>
|
||||
{moreNav.map(({ to, label, icon, exact }) => (
|
||||
<NavLink key={to} to={to} end={exact} onClick={() => setMoreOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-2 py-3 rounded-lg text-sm transition-colors ${
|
||||
isActive ? 'bg-blue-600/20 text-blue-400' : 'text-gray-300 hover:bg-gray-800'
|
||||
}`
|
||||
}>
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{inProgress && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-800">
|
||||
{syncProgress}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-800 flex items-center gap-2.5">
|
||||
<span className="w-8 h-8 flex-shrink-0 rounded-full bg-blue-600/20 text-blue-300 flex items-center justify-center text-sm font-semibold uppercase">
|
||||
{user.username?.[0] || '?'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">{user.username}</p>
|
||||
<p className={`text-xs ${user.is_admin ? 'text-amber-400' : 'text-gray-500'}`}>{role}</p>
|
||||
</div>
|
||||
<button onClick={handleLogout}
|
||||
className="text-sm text-gray-400 hover:text-red-400 transition-colors px-2 py-1">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { sportColor } from '../../utils/format'
|
||||
import { useResolvedTile } from '../../hooks/useMapSettings'
|
||||
|
||||
function decodePolyline(encoded) {
|
||||
if (!encoded) return []
|
||||
const coords = []
|
||||
let index = 0, lat = 0, lng = 0
|
||||
while (index < encoded.length) {
|
||||
let b, shift = 0, result = 0
|
||||
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
||||
lat += (result & 1) ? ~(result >> 1) : result >> 1
|
||||
shift = 0; result = 0
|
||||
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
||||
lng += (result & 1) ? ~(result >> 1) : result >> 1
|
||||
coords.push([lat / 1e5, lng / 1e5])
|
||||
}
|
||||
return coords
|
||||
}
|
||||
|
||||
// A small, non-interactive map showing the route polyline over real map tiles.
|
||||
// Wrapped in pointer-events:none so clicks fall through to the parent tile button.
|
||||
export default function RouteTileMap({ polyline, sportType, className = '' }) {
|
||||
const elRef = useRef(null)
|
||||
const mapRef = useRef(null)
|
||||
const tileRef = useRef(null)
|
||||
const tile = useResolvedTile()
|
||||
|
||||
useEffect(() => {
|
||||
if (!elRef.current || mapRef.current) return
|
||||
const map = L.map(elRef.current, {
|
||||
zoomControl: false, attributionControl: false, dragging: false,
|
||||
scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false,
|
||||
keyboard: false, touchZoom: false, tap: false, preferCanvas: true,
|
||||
// Allow fractional zoom so fitBounds fills the tile tightly instead of
|
||||
// snapping to an integer zoom that leaves wide empty margins.
|
||||
zoomSnap: 0,
|
||||
})
|
||||
mapRef.current = map
|
||||
return () => { map.remove(); mapRef.current = null; tileRef.current = null }
|
||||
}, [])
|
||||
|
||||
// Swap the base layer whenever the global map setting changes.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
if (tileRef.current) tileRef.current.remove()
|
||||
tileRef.current = L.tileLayer(tile.url, { maxZoom: tile.maxZoom, subdomains: tile.subdomains }).addTo(map)
|
||||
}, [tile])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const coords = decodePolyline(polyline)
|
||||
map.eachLayer(layer => { if (layer instanceof L.Polyline) map.removeLayer(layer) })
|
||||
if (coords.length >= 2) {
|
||||
L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.95 }).addTo(map)
|
||||
map.invalidateSize()
|
||||
map.fitBounds(L.latLngBounds(coords), { padding: [6, 6] })
|
||||
} else {
|
||||
map.setView([0, 0], 1)
|
||||
}
|
||||
}, [polyline, sportType])
|
||||
|
||||
return (
|
||||
<div className={className} style={{ pointerEvents: 'none', background: '#1a1a2e' }}>
|
||||
<div ref={elRef} style={{ height: '100%', width: '100%' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Crisp, stroke-based activity icons — replace the old emoji glyphs so sports are
|
||||
// easy to tell apart at any resolution. Renders an inline <svg> using `currentColor`
|
||||
// (or an explicit `color`), so it works both in normal JSX and nested inside another
|
||||
// SVG (e.g. recharts overlays) by passing `x`/`y`/`size`.
|
||||
//
|
||||
// viewBox is 0 0 24 24, strokeWidth 2, round caps/joins — a single coherent line set.
|
||||
|
||||
const ICONS = {
|
||||
// Runner mid-stride
|
||||
running: (
|
||||
<>
|
||||
<circle cx="13" cy="4" r="1.6" />
|
||||
<path d="M4 17l5 1l.75 -1.5" />
|
||||
<path d="M15 21v-4l-4 -3l1 -6" />
|
||||
<path d="M7 12v-3l5 -1l3 3l3 1" />
|
||||
</>
|
||||
),
|
||||
// Bicycle
|
||||
cycling: (
|
||||
<>
|
||||
<circle cx="5.5" cy="17.5" r="3.5" />
|
||||
<circle cx="18.5" cy="17.5" r="3.5" />
|
||||
<circle cx="15" cy="5" r="1" />
|
||||
<path d="M12 17.5V14l-3 -3l4 -3l2 3h2" />
|
||||
</>
|
||||
),
|
||||
// Mountain (hiking)
|
||||
hiking: (
|
||||
<>
|
||||
<path d="M3 20h18l-7 -13l-3.5 6.5l-2 -2.5z" />
|
||||
</>
|
||||
),
|
||||
// Walker
|
||||
walking: (
|
||||
<>
|
||||
<circle cx="13" cy="4" r="1.6" />
|
||||
<path d="M7 21l3 -4" />
|
||||
<path d="M16 21l-2 -4l-3 -3l1 -6" />
|
||||
<path d="M6 12l2 -3l4 -1l3 3l3 1" />
|
||||
</>
|
||||
),
|
||||
// Waves (swimming)
|
||||
swimming: (
|
||||
<>
|
||||
<path d="M2 6c.6 .5 1.2 1 2.5 1c2.5 0 2.5 -2 5 -2c2.6 0 2.4 2 5 2c2.5 0 2.5 -2 5 -2c1.3 0 1.9 .5 2.5 1" />
|
||||
<path d="M2 12c.6 .5 1.2 1 2.5 1c2.5 0 2.5 -2 5 -2c2.6 0 2.4 2 5 2c2.5 0 2.5 -2 5 -2c1.3 0 1.9 .5 2.5 1" />
|
||||
<path d="M2 18c.6 .5 1.2 1 2.5 1c2.5 0 2.5 -2 5 -2c2.6 0 2.4 2 5 2c2.5 0 2.5 -2 5 -2c1.3 0 1.9 .5 2.5 1" />
|
||||
</>
|
||||
),
|
||||
// Activity pulse (catch-all)
|
||||
other: (
|
||||
<>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
export default function SportIcon({ sport, size = 20, color = 'currentColor', x, y, className, style }) {
|
||||
const paths = ICONS[(sport || 'other').toLowerCase()] || ICONS.other
|
||||
return (
|
||||
<svg
|
||||
x={x} y={y} width={size} height={size}
|
||||
viewBox="0 0 24 24" fill="none" stroke={color}
|
||||
strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"
|
||||
className={className} style={style}
|
||||
>
|
||||
{paths}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -5,13 +5,17 @@ const accentColors = {
|
||||
green: 'text-green-400',
|
||||
orange: 'text-orange-400',
|
||||
purple: 'text-purple-400',
|
||||
violet: 'text-violet-400',
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, accent = 'default', sub }) {
|
||||
// `color` (a hex) overrides the named `accent` — used where the colour is dynamic
|
||||
// (e.g. VO2 max, coloured by its current rating category).
|
||||
export default function StatCard({ label, value, accent = 'default', color, sub }) {
|
||||
return (
|
||||
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50 h-full flex flex-col justify-center">
|
||||
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||
<p className={`text-lg font-semibold ${accentColors[accent]}`}>{value}</p>
|
||||
<p className={`text-lg font-semibold ${color ? '' : accentColors[accent]}`}
|
||||
style={color ? { color } : undefined}>{value}</p>
|
||||
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useUnitsStore } from '../../hooks/useUnits'
|
||||
|
||||
// Compact km / mi segmented toggle. Controls the global distance unit used
|
||||
// across the dashboard, activities, routes and records.
|
||||
export default function UnitToggle({ className = '' }) {
|
||||
const unit = useUnitsStore((s) => s.unit)
|
||||
const setUnit = useUnitsStore((s) => s.setUnit)
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center rounded-full bg-gray-800 p-0.5 text-xs ${className}`}>
|
||||
{['km', 'mi'].map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
onClick={() => setUnit(u)}
|
||||
className={`px-2.5 py-1 rounded-full transition-colors ${
|
||||
unit === u ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{u}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useMemo, useEffect } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { useAuthStore } from './useAuth'
|
||||
import { MAP_PROVIDERS, DEFAULT_MAP_SETTINGS, resolveTile } from '../utils/mapTiles'
|
||||
|
||||
// Global map tile preference (provider + style + per-provider API keys). The
|
||||
// source of truth is the user record on the server (so the choice and keys
|
||||
// follow the user across devices); localStorage is only a cache so maps render
|
||||
// correctly on first paint before the server hydrates. The built-in default
|
||||
// Thunderforest key is supplied by the server (not baked into this bundle).
|
||||
const CACHE = 'mapSettings'
|
||||
|
||||
function loadCache() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(CACHE))
|
||||
if (s && typeof s === 'object') return s
|
||||
} catch { /* ignore malformed cache */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
function writeCache(s) {
|
||||
localStorage.setItem(CACHE, JSON.stringify({
|
||||
provider: s.provider,
|
||||
style: s.style,
|
||||
keys: s.keys,
|
||||
defaultThunderforestKey: s.defaultThunderforestKey,
|
||||
}))
|
||||
}
|
||||
|
||||
// First style id for a provider, used when switching provider or validating.
|
||||
const firstStyle = (provider) => Object.keys(MAP_PROVIDERS[provider]?.styles || {})[0]
|
||||
|
||||
let saveTimer = null
|
||||
const cache0 = loadCache()
|
||||
|
||||
export const useMapSettingsStore = create((set, get) => ({
|
||||
provider: cache0.provider || DEFAULT_MAP_SETTINGS.provider,
|
||||
style: cache0.style || DEFAULT_MAP_SETTINGS.style,
|
||||
keys: cache0.keys || {},
|
||||
defaultThunderforestKey: cache0.defaultThunderforestKey || '',
|
||||
hydrated: false,
|
||||
dirty: false, // true while a local change is unsaved; blocks hydrate clobber
|
||||
|
||||
// Populate from the server profile. Only marks the store ready to persist
|
||||
// after this runs, so we never clobber the server with stale cache values.
|
||||
// While `dirty`, an unsaved local edit exists, so we must NOT overwrite the
|
||||
// user's choice from the (possibly stale) profile cache — a refetch landing
|
||||
// mid-edit would otherwise revert it. We still record the server default key
|
||||
// and that we've hydrated.
|
||||
hydrate: (server, defaultKey) => set((s) => {
|
||||
if (s.dirty) {
|
||||
const next = {
|
||||
...s,
|
||||
defaultThunderforestKey: defaultKey || s.defaultThunderforestKey || '',
|
||||
hydrated: true,
|
||||
}
|
||||
writeCache(next)
|
||||
return next
|
||||
}
|
||||
const ms = server || {}
|
||||
const provider = MAP_PROVIDERS[ms.provider] ? ms.provider : s.provider
|
||||
const style = MAP_PROVIDERS[provider]?.styles[ms.style] ? ms.style
|
||||
: (MAP_PROVIDERS[provider]?.styles[s.style] ? s.style : firstStyle(provider))
|
||||
const next = {
|
||||
...s,
|
||||
provider,
|
||||
style,
|
||||
keys: ms.keys || s.keys || {},
|
||||
defaultThunderforestKey: defaultKey || s.defaultThunderforestKey || '',
|
||||
hydrated: true,
|
||||
}
|
||||
writeCache(next)
|
||||
return next
|
||||
}),
|
||||
|
||||
// Debounced persist of the full settings to the user record.
|
||||
_save: () => {
|
||||
if (!get().hydrated) return
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
const snap = get()
|
||||
const payload = { provider: snap.provider, style: snap.style, keys: snap.keys }
|
||||
api.put('/profile/map-settings', payload)
|
||||
.then(() => {
|
||||
// Clear `dirty` only if nothing changed since this save was dispatched,
|
||||
// so a newer unsaved edit isn't wrongly treated as saved (and revertable).
|
||||
const cur = get()
|
||||
if (cur.provider === payload.provider && cur.style === payload.style && cur.keys === payload.keys) {
|
||||
set({ dirty: false })
|
||||
}
|
||||
})
|
||||
.catch(() => { /* non-fatal; cache keeps the local choice */ })
|
||||
}, 600)
|
||||
},
|
||||
|
||||
setProvider: (provider) => {
|
||||
set((s) => { const next = { ...s, provider, style: firstStyle(provider), dirty: true }; writeCache(next); return next })
|
||||
get()._save()
|
||||
},
|
||||
setStyle: (style) => {
|
||||
set((s) => { const next = { ...s, style, dirty: true }; writeCache(next); return next })
|
||||
get()._save()
|
||||
},
|
||||
setKey: (provider, value) => {
|
||||
set((s) => { const next = { ...s, keys: { ...s.keys, [provider]: value }, dirty: true }; writeCache(next); return next })
|
||||
get()._save()
|
||||
},
|
||||
}))
|
||||
|
||||
// Hydrate the store from the server profile once authenticated. Call once near
|
||||
// the app root (Layout). Reuses the shared ['profile'] query cache.
|
||||
export function useHydrateMapSettings() {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const hydrate = useMapSettingsStore((s) => s.hydrate)
|
||||
const { data } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: () => api.get('/profile/').then((r) => r.data),
|
||||
enabled: !!token,
|
||||
})
|
||||
useEffect(() => {
|
||||
if (data) hydrate(data.map_settings, data.thunderforest_default_key)
|
||||
}, [data])
|
||||
}
|
||||
|
||||
// Resolve the active tile layer config for a map. Pass satellite=true for an
|
||||
// imagery override (e.g. the per-activity satellite toggle). The Thunderforest
|
||||
// key falls back to the server-supplied default when the user hasn't set one.
|
||||
export function useResolvedTile(satellite = false) {
|
||||
const provider = useMapSettingsStore((s) => s.provider)
|
||||
const style = useMapSettingsStore((s) => s.style)
|
||||
const keys = useMapSettingsStore((s) => s.keys)
|
||||
const defaultTf = useMapSettingsStore((s) => s.defaultThunderforestKey)
|
||||
return useMemo(() => {
|
||||
const effectiveKeys = { ...keys, thunderforest: keys.thunderforest || defaultTf }
|
||||
return resolveTile({ provider, style, keys: effectiveKeys }, { satellite })
|
||||
}, [provider, style, keys, defaultTf, satellite])
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useMediaQuery(query) {
|
||||
const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query)
|
||||
const onChange = e => setMatches(e.matches)
|
||||
mql.addEventListener('change', onChange)
|
||||
setMatches(mql.matches)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [query])
|
||||
return matches
|
||||
}
|
||||
|
||||
// Matches Tailwind's md breakpoint — keep CSS (md:) and JS forks in agreement.
|
||||
export const useIsMobile = () => !useMediaQuery('(min-width: 768px)')
|
||||
@@ -39,17 +39,42 @@ export const useSyncStore = create((set, get) => ({
|
||||
connected: false,
|
||||
lastSyncAt: null,
|
||||
email: '',
|
||||
// Set when the user manually triggers a sync; cleared once the worker takes
|
||||
// over or finishes (see poll). prevSyncAt snapshots last_sync_at at trigger
|
||||
// time so we can detect completion without relying on clock-synced times.
|
||||
triggeredAt: null,
|
||||
prevSyncAt: null,
|
||||
|
||||
poll: async () => {
|
||||
try {
|
||||
const { data } = await api.get('/garmin-sync/config')
|
||||
const status = data?.last_sync_status ?? ''
|
||||
const inProgress = !!status && !isTerminal(status)
|
||||
const lastSyncAt = data?.last_sync_at ?? null
|
||||
let inProgress = !!status && !isTerminal(status)
|
||||
|
||||
// Grace window after a manual trigger. The Celery worker may not have
|
||||
// updated last_sync_status yet, so the config can still report the
|
||||
// PREVIOUS (terminal) status. Without this, the first poll fired right
|
||||
// after triggering would clear inProgress and the button would look dead
|
||||
// until clicked a second time. Keep the sync "in progress" until the
|
||||
// worker either starts (non-terminal status) or finishes (last_sync_at
|
||||
// changed from its pre-trigger value), with a hard cap as a safety net.
|
||||
const { triggeredAt, prevSyncAt } = get()
|
||||
if (triggeredAt) {
|
||||
const finished = lastSyncAt && lastSyncAt !== prevSyncAt
|
||||
if (inProgress || finished) {
|
||||
set({ triggeredAt: null })
|
||||
} else if (Date.now() - triggeredAt < 90000) {
|
||||
inProgress = true
|
||||
} else {
|
||||
set({ triggeredAt: null })
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
status, inProgress,
|
||||
connected: !!data?.connected,
|
||||
lastSyncAt: data?.last_sync_at ?? null,
|
||||
email: data?.email ?? '',
|
||||
lastSyncAt, email: data?.email ?? '',
|
||||
})
|
||||
return inProgress
|
||||
} catch {
|
||||
@@ -74,11 +99,11 @@ export const useSyncStore = create((set, get) => ({
|
||||
},
|
||||
|
||||
trigger: async () => {
|
||||
set({ inProgress: true, status: 'Starting sync…' })
|
||||
set({ inProgress: true, status: 'Starting sync…', triggeredAt: Date.now(), prevSyncAt: get().lastSyncAt })
|
||||
try {
|
||||
await api.post('/garmin-sync/trigger')
|
||||
} catch {
|
||||
set({ inProgress: false })
|
||||
set({ inProgress: false, triggeredAt: null })
|
||||
return
|
||||
}
|
||||
get().stopPolling()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
// Global distance-unit preference ('km' | 'mi'). Distances are always stored
|
||||
// canonically (metres / kilometres); this only controls display, converted on
|
||||
// the fly by the format helpers. Persisted to localStorage so the choice sticks
|
||||
// across reloads and is shared by every page.
|
||||
const initial = localStorage.getItem('distanceUnit') === 'mi' ? 'mi' : 'km'
|
||||
|
||||
export const useUnitsStore = create((set) => ({
|
||||
unit: initial,
|
||||
setUnit: (u) => {
|
||||
const next = u === 'mi' ? 'mi' : 'km'
|
||||
localStorage.setItem('distanceUnit', next)
|
||||
set({ unit: next })
|
||||
},
|
||||
toggle: () =>
|
||||
set((s) => {
|
||||
const next = s.unit === 'km' ? 'mi' : 'km'
|
||||
localStorage.setItem('distanceUnit', next)
|
||||
return { unit: next }
|
||||
}),
|
||||
}))
|
||||
|
||||
// Convenience hook: subscribe to just the active unit string.
|
||||
export const useUnit = () => useUnitsStore((s) => s.unit)
|
||||
@@ -1,96 +1,158 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||
formatDate, sportIcon, sportColor,
|
||||
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
|
||||
formatDate, sportColor, distanceUnitLabel,
|
||||
} from '../utils/format'
|
||||
import { useUnit } from '../hooks/useUnits'
|
||||
import SportIcon from '../components/ui/SportIcon'
|
||||
|
||||
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||
const KM_PER_MI = 1.609344
|
||||
const FALLBACK_SPORTS = ['running', 'cycling', 'hiking', 'walking']
|
||||
|
||||
export default function ActivitiesPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [sport, setSport] = useState('all')
|
||||
const unit = useUnit()
|
||||
const distLabel = distanceUnitLabel(unit)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const fromParam = searchParams.get('from')
|
||||
const toParam = searchParams.get('to')
|
||||
// Filters
|
||||
const [sport, setSport] = useState('all')
|
||||
const [year, setYear] = useState('all')
|
||||
const [minDist, setMinDist] = useState('')
|
||||
const [maxDist, setMaxDist] = useState('')
|
||||
const [fromDate, setFromDate] = useState(searchParams.get('from') || '')
|
||||
const [toDate, setToDate] = useState(searchParams.get('to') || '')
|
||||
|
||||
// Deep link from the dashboard weekly chart arrives as ?from&to.
|
||||
useEffect(() => {
|
||||
const f = searchParams.get('from'); const t = searchParams.get('to')
|
||||
if (f) setFromDate(f)
|
||||
if (t) setToDate(t)
|
||||
}, [searchParams])
|
||||
|
||||
// Reset to page 1 whenever a filter changes.
|
||||
useEffect(() => { setPage(1) }, [sport, year, minDist, maxDist, fromDate, toDate])
|
||||
|
||||
const { data: filterOpts } = useQuery({
|
||||
queryKey: ['activity-filters'],
|
||||
queryFn: () => api.get('/activities/stats/filters').then(r => r.data),
|
||||
})
|
||||
const sportTypes = filterOpts?.sport_types?.length ? filterOpts.sport_types : FALLBACK_SPORTS
|
||||
const years = filterOpts?.years || []
|
||||
|
||||
const toKm = v => {
|
||||
const n = parseFloat(v)
|
||||
if (isNaN(n)) return undefined
|
||||
return unit === 'mi' ? n * KM_PER_MI : n
|
||||
}
|
||||
|
||||
const { data: activities, isLoading } = useQuery({
|
||||
queryKey: ['activities', sport, page, fromParam, toParam],
|
||||
queryKey: ['activities', sport, year, minDist, maxDist, fromDate, toDate, page, unit],
|
||||
queryFn: () =>
|
||||
api.get('/activities/', {
|
||||
params: {
|
||||
sport_type: sport === 'all' ? undefined : sport,
|
||||
year: year === 'all' ? undefined : year,
|
||||
min_distance_km: toKm(minDist),
|
||||
max_distance_km: toKm(maxDist),
|
||||
from_date: fromDate ? new Date(fromDate).toISOString() : undefined,
|
||||
to_date: toDate ? new Date(toDate + 'T23:59:59').toISOString() : undefined,
|
||||
page,
|
||||
per_page: 20,
|
||||
from_date: fromParam ? new Date(fromParam).toISOString() : undefined,
|
||||
to_date: toParam ? new Date(toParam + 'T23:59:59').toISOString() : undefined,
|
||||
},
|
||||
}).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: ytdStats } = useQuery({
|
||||
queryKey: ['ytd-stats'],
|
||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||
})
|
||||
const anyFilter = sport !== 'all' || year !== 'all' || minDist || maxDist || fromDate || toDate
|
||||
const clearFilters = () => {
|
||||
setSport('all'); setYear('all'); setMinDist(''); setMaxDist(''); setFromDate(''); setToDate('')
|
||||
}
|
||||
|
||||
const clearDateFilter = () => navigate('/activities')
|
||||
// Totals for the visible results — only meaningful (complete) when everything
|
||||
// fits on one page; otherwise they'd be a misleading partial sum.
|
||||
const singlePage = (activities?.length ?? 0) < 20
|
||||
const totalDistanceM = activities?.reduce((sum, a) => sum + (a.distance_m || 0), 0) || 0
|
||||
const totalDurationS = activities?.reduce((sum, a) => sum + (a.duration_s || 0), 0) || 0
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||||
<Link
|
||||
to="/upload"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/summary" className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
📈 Summary
|
||||
</Link>
|
||||
<Link to="/upload" className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
+ Import
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* YTD stats */}
|
||||
{ytdStats && (
|
||||
<div className="flex gap-4 mb-4 text-sm">
|
||||
{ytdStats.running_km > 0 && (
|
||||
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
|
||||
)}
|
||||
{ytdStats.cycling_km > 0 && (
|
||||
<span className="text-orange-400">🚴 {ytdStats.cycling_km.toFixed(0)} km this year</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date filter chip */}
|
||||
{fromParam && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xs bg-blue-600/20 text-blue-300 border border-blue-500/30 px-3 py-1 rounded-full">
|
||||
Week of {format(new Date(fromParam), 'MMM d, yyyy')}
|
||||
</span>
|
||||
<button onClick={clearDateFilter} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">✕ Clear</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sport filter */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{SPORTS.map(s => (
|
||||
{/* Sport filter chips */}
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{['all', ...sportTypes].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setSport(s); setPage(1) }}
|
||||
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors ${
|
||||
onClick={() => setSport(s)}
|
||||
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors inline-flex items-center gap-1.5 ${
|
||||
sport === s
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-gray-700 text-gray-400 hover:text-white hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
{s === 'all' ? 'All' : `${sportIcon(s)} ${s}`}
|
||||
{s !== 'all' && <SportIcon sport={s} size={15} color="currentColor" />}
|
||||
{s === 'all' ? 'All' : s.replace(/_/g, ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Year / distance / date filters */}
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-gray-500">Year</span>
|
||||
<select value={year} onChange={e => setYear(e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="all">All years</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-gray-500">From</span>
|
||||
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-gray-500">To</span>
|
||||
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-gray-500">Min {distLabel}</span>
|
||||
<input type="number" min="0" step="0.1" value={minDist} onChange={e => setMinDist(e.target.value)}
|
||||
className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-gray-500">Max {distLabel}</span>
|
||||
<input type="number" min="0" step="0.1" value={maxDist} onChange={e => setMaxDist(e.target.value)}
|
||||
className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</label>
|
||||
{anyFilter && (
|
||||
<button onClick={clearFilters} className="text-xs text-gray-500 hover:text-gray-300 transition-colors pb-2">✕ Clear filters</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtered totals (only when the result set is a single page) */}
|
||||
{anyFilter && activities?.length > 0 && singlePage && (
|
||||
<div className="mb-4 text-sm text-gray-400">
|
||||
<span className="text-gray-200 font-medium">{formatDistance(totalDistanceM, unit)}</span> ·{' '}
|
||||
<span className="text-gray-200 font-medium">{formatDuration(totalDurationS)}</span> ·{' '}
|
||||
{activities.length} {activities.length === 1 ? 'activity' : 'activities'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity list */}
|
||||
{isLoading ? (
|
||||
<div className="text-gray-500 text-sm">Loading…</div>
|
||||
@@ -100,14 +162,14 @@ export default function ActivitiesPage() {
|
||||
<Link
|
||||
key={activity.id}
|
||||
to={`/activities/${activity.id}`}
|
||||
className="flex items-center gap-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl p-4 transition-all group"
|
||||
className="flex items-center gap-3 p-3 sm:gap-4 sm:p-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl transition-all group"
|
||||
>
|
||||
{/* Sport indicator */}
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 text-lg"
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: sportColor(activity.sport_type) + '22' }}
|
||||
>
|
||||
{sportIcon(activity.sport_type)}
|
||||
<SportIcon sport={activity.sport_type} size={20} color={sportColor(activity.sport_type)} />
|
||||
</div>
|
||||
|
||||
{/* Name + date */}
|
||||
@@ -115,13 +177,20 @@ export default function ActivitiesPage() {
|
||||
<p className="font-medium text-white group-hover:text-blue-400 transition-colors truncate">
|
||||
{activity.name}
|
||||
</p>
|
||||
{activity.original_name && (
|
||||
<p className="text-xs text-gray-600 truncate">orig. {activity.original_name}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
|
||||
{/* Compact metrics line — the full metrics column is hidden below sm */}
|
||||
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
|
||||
{formatDistance(activity.distance_m, unit)} · {formatDuration(activity.duration_s)} · {formatPace(activity.avg_speed_ms, activity.sport_type, unit)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="hidden sm:flex items-center gap-6 text-sm">
|
||||
<div className="text-right">
|
||||
<p className="text-gray-200 font-medium">{formatDistance(activity.distance_m)}</p>
|
||||
<p className="text-gray-200 font-medium">{formatDistance(activity.distance_m, unit)}</p>
|
||||
<p className="text-xs text-gray-600">distance</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -129,7 +198,7 @@ export default function ActivitiesPage() {
|
||||
<p className="text-xs text-gray-600">time</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-gray-200 font-medium">{formatPace(activity.avg_speed_ms, activity.sport_type)}</p>
|
||||
<p className="text-gray-200 font-medium">{formatPace(activity.avg_speed_ms, activity.sport_type, unit)}</p>
|
||||
<p className="text-xs text-gray-600">pace</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -138,7 +207,7 @@ export default function ActivitiesPage() {
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-gray-200 font-medium">
|
||||
{activity.elevation_gain_m ? `↑ ${Math.round(activity.elevation_gain_m)}m` : '--'}
|
||||
{activity.elevation_gain_m ? `↑ ${formatElevation(activity.elevation_gain_m, unit)}` : '--'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">elev</p>
|
||||
</div>
|
||||
@@ -150,7 +219,7 @@ export default function ActivitiesPage() {
|
||||
|
||||
{activities?.length === 0 && (
|
||||
<div className="text-center py-16 text-gray-600">
|
||||
<p className="text-4xl mb-3">🏃</p>
|
||||
<SportIcon sport="running" size={44} color="currentColor" className="mx-auto mb-3" />
|
||||
<p className="text-lg">No activities yet</p>
|
||||
<p className="text-sm mt-1">
|
||||
<Link to="/upload" className="text-blue-400 hover:underline">Import your Garmin or Strava data</Link> to get started
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useMemo } from 'react'
|
||||
import api from '../utils/api'
|
||||
@@ -11,13 +11,15 @@ import RouteLeaderboard from '../components/activity/RouteLeaderboard'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatElevation,
|
||||
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
||||
formatHeartRate, formatDateTime, formatCadence, sportColor,
|
||||
} from '../utils/format'
|
||||
import { useUnit } from '../hooks/useUnits'
|
||||
import SportIcon from '../components/ui/SportIcon'
|
||||
|
||||
import { projectToTrack } from '../utils/track'
|
||||
|
||||
const METRICS = [
|
||||
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
||||
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#ef4444' },
|
||||
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
||||
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
|
||||
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
|
||||
@@ -27,14 +29,21 @@ const METRICS = [
|
||||
|
||||
export default function ActivityDetailPage() {
|
||||
const { id } = useParams()
|
||||
const unit = useUnit()
|
||||
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
||||
const [hoveredDistance, setHoveredDistance] = useState(null)
|
||||
const [mapHeight, setMapHeight] = useState(420)
|
||||
const [mapType, setMapType] = useState('street')
|
||||
const [satellite, setSatellite] = useState(false)
|
||||
const [colorMode, setColorMode] = useState('speed')
|
||||
const [segCreate, setSegCreate] = useState(false)
|
||||
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
|
||||
const [segName, setSegName] = useState('')
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [nameInput, setNameInput] = useState('')
|
||||
const [nameError, setNameError] = useState('')
|
||||
const [routeCreate, setRouteCreate] = useState(false)
|
||||
const [routeName, setRouteName] = useState('')
|
||||
const [routeError, setRouteError] = useState('')
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: activity, isLoading } = useQuery({
|
||||
@@ -66,6 +75,12 @@ export default function ActivityDetailPage() {
|
||||
enabled: !!activity?.named_route_id,
|
||||
})
|
||||
|
||||
const { data: activityRecords } = useQuery({
|
||||
queryKey: ['activity-records', id],
|
||||
queryFn: () => api.get(`/activities/${id}/records`).then(r => r.data),
|
||||
enabled: !!activity,
|
||||
})
|
||||
|
||||
const { data: routeBoard } = useQuery({
|
||||
queryKey: ['route-leaderboard', id],
|
||||
queryFn: () => api.get(`/activities/${id}/route-leaderboard`).then(r => r.data),
|
||||
@@ -98,6 +113,40 @@ export default function ActivityDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const createRoute = async () => {
|
||||
const name = routeName.trim()
|
||||
if (!name) { setRouteError('Name cannot be empty'); return }
|
||||
setRouteError('')
|
||||
try {
|
||||
await api.post('/routes/', { name, activity_id: Number(id) })
|
||||
setRouteCreate(false); setRouteName('')
|
||||
qc.invalidateQueries({ queryKey: ['activity', id] })
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
} catch (e) {
|
||||
setRouteError(e.response?.data?.detail || 'Failed to create route')
|
||||
}
|
||||
}
|
||||
|
||||
const startRename = () => {
|
||||
setNameInput(activity.name)
|
||||
setNameError('')
|
||||
setEditingName(true)
|
||||
}
|
||||
const saveName = async () => {
|
||||
const next = nameInput.trim()
|
||||
if (!next) { setNameError('Name cannot be empty'); return }
|
||||
if (next === activity.name) { setEditingName(false); return }
|
||||
setNameError('')
|
||||
try {
|
||||
await api.patch(`/activities/${id}/name`, { name: next })
|
||||
setEditingName(false)
|
||||
qc.invalidateQueries({ queryKey: ['activity', id] })
|
||||
qc.invalidateQueries({ queryKey: ['activities'] })
|
||||
} catch (e) {
|
||||
setNameError(e.response?.data?.detail || 'Failed to rename')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMetric = (key) => {
|
||||
setActiveMetrics(prev =>
|
||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||
@@ -120,32 +169,111 @@ export default function ActivityDetailPage() {
|
||||
if (!activity) return null
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-2xl">{sportIcon(activity.sport_type)}</span>
|
||||
<h1 className="text-2xl font-bold text-white">{activity.name}</h1>
|
||||
<SportIcon sport={activity.sport_type} size={26} color={sportColor(activity.sport_type)} className="shrink-0" />
|
||||
{editingName ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={nameInput}
|
||||
onChange={e => setNameInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveName()
|
||||
// Reset to current name so the onBlur save becomes a no-op.
|
||||
if (e.key === 'Escape') { setNameInput(activity.name); setEditingName(false) }
|
||||
}}
|
||||
onBlur={saveName}
|
||||
className="text-2xl font-bold bg-gray-800 text-white rounded px-2 py-0.5 border border-gray-600 focus:border-blue-500 focus:outline-none min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-white break-words min-w-0">{activity.name}</h1>
|
||||
<button
|
||||
onClick={startRename}
|
||||
title="Rename activity"
|
||||
className="text-gray-500 hover:text-white transition-colors shrink-0"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{nameError && <p className="text-xs text-red-400 mb-1">{nameError}</p>}
|
||||
{activity.original_name && (
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
Originally <span className="text-gray-400">{activity.original_name}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p>
|
||||
{/* Named route link / create-route control */}
|
||||
{activity.named_route_id ? (
|
||||
<p className="text-sm text-blue-400 mt-1">
|
||||
📍 <Link to={`/routes/${activity.named_route_id}`} className="hover:underline">{activity.named_route_name}</Link>
|
||||
</p>
|
||||
) : activity.polyline && activity.distance_m > 0 ? (
|
||||
routeCreate ? (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={routeName}
|
||||
onChange={e => setRouteName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') createRoute()
|
||||
if (e.key === 'Escape') { setRouteCreate(false); setRouteError('') }
|
||||
}}
|
||||
placeholder="Route name"
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button onClick={createRoute} disabled={!routeName.trim()}
|
||||
className="text-sm bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white px-3 py-1 rounded-lg">Create route</button>
|
||||
<button onClick={() => { setRouteCreate(false); setRouteError('') }}
|
||||
className="text-sm text-gray-400 hover:text-white px-1">Cancel</button>
|
||||
{routeError && <span className="text-xs text-red-400">{routeError}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setRouteName(activity.name); setRouteCreate(true); setRouteError('') }}
|
||||
className="text-sm text-gray-500 hover:text-blue-400 mt-1 transition-colors"
|
||||
>
|
||||
+ Create route from this activity
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Personal records set in this activity */}
|
||||
{activityRecords && activityRecords.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-gray-500 mr-1">🏆 Personal best{activityRecords.length > 1 ? 's' : ''} set here:</span>
|
||||
{activityRecords.map(pr => (
|
||||
<span
|
||||
key={pr.distance_label}
|
||||
className="text-xs px-2.5 py-1 rounded-full bg-yellow-500/10 text-yellow-400 border border-yellow-500/30 font-medium"
|
||||
>
|
||||
{pr.distance_label} · {formatDuration(pr.duration_s)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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)} />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-10 gap-3">
|
||||
<StatCard label="Distance" value={formatDistance(activity.distance_m, unit)} />
|
||||
<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="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type, unit)} />
|
||||
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m, unit)} />
|
||||
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
||||
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
||||
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
|
||||
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m, unit)} />
|
||||
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
||||
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
||||
</div>
|
||||
@@ -164,20 +292,17 @@ export default function ActivityDetailPage() {
|
||||
{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">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Map style:</span>
|
||||
{['dark', 'street', 'satellite'].map(t => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-2 px-4 py-2 border-b border-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setMapType(t)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
|
||||
mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||
onClick={() => setSatellite(s => !s)}
|
||||
title="Map tiles are chosen globally in Profile › Map & Tiles"
|
||||
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
||||
satellite ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
🛰 Satellite
|
||||
</button>
|
||||
))}
|
||||
{dataPoints?.length > 0 && (
|
||||
<button
|
||||
onClick={() => { setSegCreate(c => !c); setSegPoints([]); setSegName('') }}
|
||||
@@ -202,7 +327,8 @@ export default function ActivityDetailPage() {
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-xs text-gray-500 ml-2">Height:</span>
|
||||
<div className="hidden sm:flex items-center gap-2 ml-2">
|
||||
<span className="text-xs text-gray-500">Height:</span>
|
||||
{[280, 420, 560].map(h => (
|
||||
<button
|
||||
key={h}
|
||||
@@ -216,14 +342,15 @@ export default function ActivityDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{segCreate && (
|
||||
<div className="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-gray-800 bg-green-900/10 text-xs">
|
||||
<span className="text-green-400">
|
||||
Click two points on the route to mark the segment start and end.
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
Start: {segPoints[0] ? `${(segPoints[0].distance_m / 1000).toFixed(2)} km` : '—'}
|
||||
{' · '}End: {segPoints[1] ? `${(segPoints[1].distance_m / 1000).toFixed(2)} km` : '—'}
|
||||
Start: {segPoints[0] ? formatDistance(segPoints[0].distance_m, unit) : '—'}
|
||||
{' · '}End: {segPoints[1] ? formatDistance(segPoints[1].distance_m, unit) : '—'}
|
||||
</span>
|
||||
{segPoints.length === 2 && (
|
||||
<>
|
||||
@@ -251,7 +378,7 @@ export default function ActivityDetailPage() {
|
||||
dataPoints={dataPoints}
|
||||
hoveredDistance={hoveredDistance}
|
||||
sportType={activity.sport_type}
|
||||
mapType={mapType}
|
||||
satellite={satellite}
|
||||
colorMode={colorMode}
|
||||
onMapClick={segCreate ? handleMapClick : undefined}
|
||||
/>
|
||||
@@ -272,7 +399,7 @@ export default function ActivityDetailPage() {
|
||||
|
||||
{/* Metric timeline */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
|
||||
@@ -295,9 +422,10 @@ export default function ActivityDetailPage() {
|
||||
<MetricTimeline
|
||||
dataPoints={dataPoints}
|
||||
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
|
||||
metrics={METRICS}
|
||||
metrics={METRICS.map(m => m.key === 'altitude_m' ? { ...m, unit: unit === 'mi' ? 'ft' : 'm' } : m)}
|
||||
onHoverDistance={setHoveredDistance}
|
||||
sportType={activity.sport_type}
|
||||
unit={unit}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
|
||||
@@ -312,7 +440,7 @@ export default function ActivityDetailPage() {
|
||||
{laps && laps.length > 0 && (
|
||||
<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} />
|
||||
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} records={activityRecords} unit={unit} />
|
||||
</div>
|
||||
)}
|
||||
{routeBoard && routeBoard.top?.length > 0 && (
|
||||
|
||||
@@ -2,43 +2,42 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
BarChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
BarChart, Bar, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import GridLayout, { WidthProvider } from 'react-grid-layout'
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
import 'react-resizable/css/styles.css'
|
||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import HrvBadge from '../components/ui/HrvBadge'
|
||||
import SleepHypnogram from '../components/health/SleepHypnogram'
|
||||
import BodyBatteryChart from '../components/health/BodyBatteryChart'
|
||||
import ActivityMap from '../components/activity/ActivityMap'
|
||||
import {
|
||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||
formatDate, sportIcon, sportColor, formatSleep,
|
||||
formatDate, sportColor, formatSleep, convertKm, distanceUnitLabel,
|
||||
} from '../utils/format'
|
||||
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
||||
import { useUnit } from '../hooks/useUnits'
|
||||
import SportIcon from '../components/ui/SportIcon'
|
||||
import { vo2Color } from '../utils/vo2'
|
||||
|
||||
const Grid = WidthProvider(GridLayout)
|
||||
|
||||
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, color: '#fff' }
|
||||
|
||||
const HRV_PALETTE = {
|
||||
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
||||
unbalanced: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
||||
low: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||
}
|
||||
|
||||
// Compact single-stat widgets. val(health, ytdStats) → display string.
|
||||
const STAT_DEFS = {
|
||||
stat_steps: { label: 'Steps today', accent: 'green', sub: 'goal 10,000', val: h => h.steps != null ? h.steps.toLocaleString() : '--' },
|
||||
stat_resting_hr: { label: 'Resting HR', accent: 'red', val: h => formatHeartRate(h.resting_hr) },
|
||||
stat_sleep: { label: 'Sleep', accent: 'default', val: h => formatSleep(h.sleep_duration_s) },
|
||||
stat_sleep: { label: 'Sleep', accent: 'violet', val: h => formatSleep(h.sleep_duration_s) },
|
||||
stat_vo2max: { label: 'VO₂ max', accent: 'blue', val: h => h.vo2max != null ? h.vo2max.toFixed(1) : '--', sub: h => h.fitness_age != null ? `fitness age ${h.fitness_age}` : undefined },
|
||||
stat_hrv: { label: 'HRV status', accent: 'purple', val: h => h.hrv_nightly_avg != null ? `${Math.round(h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status || undefined },
|
||||
stat_running: { label: 'Running this year', accent: 'blue', val: (h, y) => y ? `${y.running_km.toFixed(0)} km` : '--' },
|
||||
stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y) => y ? `${y.cycling_km.toFixed(0)} km` : '--' },
|
||||
stat_stress: { label: 'Stress', accent: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' },
|
||||
stat_hrv: { label: 'HRV status', accent: 'purple', val: h => (h.hrv_weekly_avg ?? h.hrv_nightly_avg) != null ? `${Math.round(h.hrv_weekly_avg ?? h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status ? <HrvBadge status={h.hrv_status} /> : undefined },
|
||||
stat_running: { label: 'Running this year', accent: 'green', val: (h, y, u) => y ? `${convertKm(y.running_km, u).toFixed(0)} ${distanceUnitLabel(u)}` : '--' },
|
||||
stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y, u) => y ? `${convertKm(y.cycling_km, u).toFixed(0)} ${distanceUnitLabel(u)}` : '--' },
|
||||
stat_stress: { label: 'Stress', accent: 'orange', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' },
|
||||
stat_calories: { label: 'Active calories', accent: 'orange', val: h => h.active_calories != null ? Math.round(h.active_calories).toLocaleString() : '--' },
|
||||
stat_floors: { label: 'Floors climbed', accent: 'green', val: h => h.floors_climbed != null ? h.floors_climbed : '--' },
|
||||
}
|
||||
@@ -108,59 +107,15 @@ function Stat({ label, value }) {
|
||||
|
||||
// ── Chart widgets ────────────────────────────────────────────────────────────
|
||||
|
||||
function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd }) {
|
||||
const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ t: ts, level }))
|
||||
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
|
||||
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
|
||||
const data = raw.map((d, i) => ({
|
||||
...d,
|
||||
type: inferBBType(d.t, d.level, i > 0 ? raw[i - 1].level : null, sleepStartMs, sleepEndMs),
|
||||
}))
|
||||
const charged = bb?.charged, drained = bb?.drained, end_level = bb?.end_level
|
||||
const peak = data.length ? Math.max(...data.map(d => d.level)) : end_level
|
||||
const hasGraph = data.length >= 2
|
||||
const presentTypes = [...new Set(data.map(d => d.type))]
|
||||
|
||||
// Body Battery widget — wraps the shared BodyBatteryChart in a dashboard Card.
|
||||
// The dashboard variant fills its grid cell (fill) and uses a compact y-axis.
|
||||
function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd, activities }) {
|
||||
return (
|
||||
<Card title="Body Battery" viewHref="/health">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
{peak != null && <span className="text-3xl font-bold" style={{ color: bbLevelColor(peak) }}>{Math.round(peak)}</span>}
|
||||
{charged != null && <span className="text-sm font-semibold text-green-400">+{charged}</span>}
|
||||
{drained != null && <span className="text-sm font-semibold text-orange-400">-{drained}</span>}
|
||||
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
|
||||
</div>
|
||||
{hasGraph ? (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 mt-2">
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={80}>
|
||||
<BarChart data={data} margin={{ top: 2, right: 4, bottom: 0, left: 0 }} barCategoryGap={0}>
|
||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
interval={Math.max(1, Math.floor(data.length / 6))} />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
width={26} ticks={[0, 50, 100]} />
|
||||
<Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)}%`, 'Battery']} />
|
||||
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
||||
{data.map((d, i) => <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{presentTypes.map(type => (
|
||||
<div key={type} className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
|
||||
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 mt-3">No body battery data today</p>
|
||||
)}
|
||||
</div>
|
||||
<BodyBatteryChart
|
||||
bb={bb} hires={hires} sleepStart={sleepStart} sleepEnd={sleepEnd} activities={activities}
|
||||
fill yTicks={[0, 50, 100]} yAxisWidth={26} leftMargin={0} iconSize={13}
|
||||
emptyText="No body battery data today" />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -185,24 +140,25 @@ function Sparkline({ data, dataKey, color, gradId, fmt }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Vo2MaxTrend({ health, recentHealth }) {
|
||||
function Vo2MaxTrend({ health, recentHealth, profile }) {
|
||||
const series = useMemo(
|
||||
() => [...(recentHealth || [])].filter(d => d.vo2max != null)
|
||||
.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
.map(d => ({ date: d.date, v: d.vo2max })),
|
||||
[recentHealth],
|
||||
)
|
||||
const color = vo2Color(health.vo2max, profile?.birth_year, profile?.biological_sex)
|
||||
return (
|
||||
<Card title="VO₂ Max" viewHref="/health">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-blue-400">{health.vo2max != null ? health.vo2max.toFixed(1) : '--'}</span>
|
||||
<span className="text-3xl font-bold" style={{ color }}>{health.vo2max != null ? health.vo2max.toFixed(1) : '--'}</span>
|
||||
<span className="text-xs text-gray-500">ml/kg/min</span>
|
||||
</div>
|
||||
{health.fitness_age != null && <p className="text-xs text-gray-500 mt-0.5">Fitness age {health.fitness_age}</p>}
|
||||
<div className="flex-1 min-h-0 mt-2">
|
||||
{series.length >= 2
|
||||
? <Sparkline data={series} dataKey="v" color="#3b82f6" gradId="grad-dash-vo2" fmt={v => v.toFixed(1)} />
|
||||
? <Sparkline data={series} dataKey="v" color={color} gradId="grad-dash-vo2" fmt={v => v.toFixed(1)} />
|
||||
: <p className="text-xs text-gray-600">Not enough history</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,12 +178,12 @@ function WeightMini({ recentHealth }) {
|
||||
<Card title="Weight" viewHref="/health">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-emerald-300">{latest != null ? latest.toFixed(1) : '--'}</span>
|
||||
<span className="text-3xl font-bold text-blue-400">{latest != null ? latest.toFixed(1) : '--'}</span>
|
||||
<span className="text-xs text-gray-500">kg</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 mt-2">
|
||||
{series.length >= 2
|
||||
? <Sparkline data={series} dataKey="w" color="#34d399" gradId="grad-dash-weight" fmt={v => `${v.toFixed(1)} kg`} />
|
||||
? <Sparkline data={series} dataKey="w" color="#3b82f6" gradId="grad-dash-weight" fmt={v => `${v.toFixed(1)} kg`} />
|
||||
: <p className="text-xs text-gray-600">Not enough history</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,19 +191,22 @@ function WeightMini({ recentHealth }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Canonical sleep-stage palette — matches the Health page hypnogram/charts.
|
||||
const SLEEP_STAGES = [
|
||||
{ key: 'sleep_deep_s', label: 'Deep', color: '#3b82f6' },
|
||||
{ key: 'sleep_rem_s', label: 'REM', color: '#8b5cf6' },
|
||||
{ key: 'sleep_light_s', label: 'Light', color: '#60a5fa' },
|
||||
{ key: 'sleep_awake_s', label: 'Awake', color: '#6b7280' },
|
||||
{ key: 'sleep_deep_s', label: 'Deep', color: '#6366f1' },
|
||||
{ key: 'sleep_rem_s', label: 'REM', color: '#7c3aed' },
|
||||
{ key: 'sleep_light_s', label: 'Light', color: '#a78bfa' },
|
||||
{ key: 'sleep_awake_s', label: 'Awake', color: '#eab308' },
|
||||
]
|
||||
|
||||
function SleepDetail({ health }) {
|
||||
function SleepDetail({ health, sleepStages }) {
|
||||
const total = SLEEP_STAGES.reduce((s, st) => s + (health[st.key] || 0), 0)
|
||||
const hasHypnogram = health.sleep_start && health.sleep_end && sleepStages?.length
|
||||
return (
|
||||
<Card title="Sleep" viewHref="/health">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
<span className="text-3xl font-bold text-indigo-300">{formatSleep(health.sleep_duration_s)}</span>
|
||||
<span className="text-3xl font-bold text-violet-400">{formatSleep(health.sleep_duration_s)}</span>
|
||||
{health.sleep_score != null && (
|
||||
<span className="text-sm text-gray-400">score <span className="text-white font-semibold">{Math.round(health.sleep_score)}</span></span>
|
||||
)}
|
||||
@@ -270,10 +229,18 @@ function SleepDetail({ health }) {
|
||||
</div>
|
||||
) : null))}
|
||||
</div>
|
||||
{hasHypnogram && (
|
||||
<div className="flex-1 flex items-center mt-4">
|
||||
<div className="w-full">
|
||||
<SleepHypnogram sleepStart={health.sleep_start} sleepEnd={health.sleep_end} stages={sleepStages} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 mt-3">No sleep stages for last night</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -282,6 +249,8 @@ const sportLabel = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Other')
|
||||
|
||||
function WeeklyChart({ activities }) {
|
||||
const navigate = useNavigate()
|
||||
const unit = useUnit()
|
||||
const distLabel = distanceUnitLabel(unit)
|
||||
const { data, sports } = useMemo(() => {
|
||||
if (!activities?.length) return { data: [], sports: [] }
|
||||
// Sports present, ordered by total distance (largest stacks at the bottom).
|
||||
@@ -298,14 +267,14 @@ function WeeklyChart({ activities }) {
|
||||
const t = new Date(a.start_time)
|
||||
if (t >= weekStart && t < weekEnd) row[a.sport_type] += (a.distance_m || 0) / 1000
|
||||
}
|
||||
for (const s of sports) row[s] = +row[s].toFixed(2)
|
||||
for (const s of sports) row[s] = +convertKm(row[s], unit).toFixed(2)
|
||||
return row
|
||||
})
|
||||
return { data, sports }
|
||||
}, [activities])
|
||||
}, [activities, unit])
|
||||
|
||||
return (
|
||||
<Card title="Weekly distance (km)">
|
||||
<Card title={`Weekly distance (${distLabel})`}>
|
||||
{data.length ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
@@ -317,7 +286,7 @@ function WeeklyChart({ activities }) {
|
||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28} tickFormatter={v => `${v.toFixed(0)}`} />
|
||||
<Tooltip contentStyle={tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.06)' }}
|
||||
formatter={(v, name) => [`${(+v).toFixed(1)} km`, sportLabel(name)]} />
|
||||
formatter={(v, name) => [`${(+v).toFixed(1)} ${distLabel}`, sportLabel(name)]} />
|
||||
{sports.map((s, i) => (
|
||||
<Bar key={s} dataKey={s} stackId="dist" fill={sportColor(s)} isAnimationActive={false}
|
||||
radius={i === sports.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]} />
|
||||
@@ -342,6 +311,7 @@ function WeeklyChart({ activities }) {
|
||||
}
|
||||
|
||||
function FeaturedActivity({ activity, segments }) {
|
||||
const unit = useUnit()
|
||||
if (!activity) return (
|
||||
<Card title="Latest activity"><div className="flex items-center justify-center h-full text-gray-600 text-sm">No activities yet</div></Card>
|
||||
)
|
||||
@@ -349,7 +319,7 @@ function FeaturedActivity({ activity, segments }) {
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xl">{sportIcon(activity.sport_type)}</span>
|
||||
<SportIcon sport={activity.sport_type} size={22} color={sportColor(activity.sport_type)} className="shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<Link to={`/activities/${activity.id}`} className="text-sm font-semibold text-white hover:text-blue-400 transition-colors truncate block">{activity.name}</Link>
|
||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||
@@ -360,12 +330,12 @@ function FeaturedActivity({ activity, segments }) {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 flex-1 min-h-0">
|
||||
<div className="lg:col-span-2 min-h-[180px] bg-gray-950">
|
||||
{activity.polyline
|
||||
? <ActivityMap polyline={activity.polyline} sportType={activity.sport_type} colorMode="solid" mapType="dark" />
|
||||
? <ActivityMap polyline={activity.polyline} sportType={activity.sport_type} colorMode="solid" />
|
||||
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
|
||||
<Stat label="Distance" value={formatDistance(activity.distance_m)} />
|
||||
<Stat label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
||||
<Stat label="Distance" value={formatDistance(activity.distance_m, unit)} />
|
||||
<Stat label="Elevation ↑" value={formatElevation(activity.elevation_gain_m, unit)} />
|
||||
<Stat label="Moving time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)} />
|
||||
<Stat label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||
</div>
|
||||
@@ -400,27 +370,60 @@ function FeaturedActivity({ activity, segments }) {
|
||||
}
|
||||
|
||||
function RecentActivities({ activities }) {
|
||||
const unit = useUnit()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const createRoute = async (activity, e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const name = window.prompt('Name for the new route:', activity.name)
|
||||
if (!name || !name.trim()) return
|
||||
try {
|
||||
await api.post('/routes/', { name: name.trim(), activity_id: activity.id })
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
qc.invalidateQueries({ queryKey: ['activities-recent'] })
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.detail || 'Failed to create route')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Recent activities" viewHref="/activities">
|
||||
<div className="space-y-2 overflow-auto h-full">
|
||||
{activities?.slice(0, 6).map(activity => (
|
||||
{activities?.length ? (
|
||||
// Rows flex to fill the card height so the list always fits exactly —
|
||||
// no scrollbars (and never a spurious horizontal one). The visible count
|
||||
// adapts to the widget's height rather than a fixed slice overflowing.
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{activities.slice(0, 6).map(activity => (
|
||||
<Link key={activity.id} to={`/activities/${activity.id}`}
|
||||
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
||||
className="group flex items-center gap-3 flex-1 min-h-0 overflow-hidden px-2 border-b border-gray-800/50 last:border-0 hover:bg-gray-800/30 rounded-lg transition-colors">
|
||||
<SportIcon sport={activity.sport_type} size={20} color={sportColor(activity.sport_type)} className="shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
||||
{activity.named_route_name && (
|
||||
<p className="text-xs text-blue-400 truncate">📍 {activity.named_route_name}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p>
|
||||
{!activity.named_route_id && activity.polyline && activity.distance_m > 0 && (
|
||||
<button
|
||||
onClick={e => createRoute(activity, e)}
|
||||
title="Create route from this activity"
|
||||
className="shrink-0 text-gray-600 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity text-sm px-1"
|
||||
>
|
||||
📍+
|
||||
</button>
|
||||
)}
|
||||
<div className="text-right text-sm shrink-0">
|
||||
<p className="text-gray-200">{formatDistance(activity.distance_m, unit)}</p>
|
||||
<p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{!activities?.length && (
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-600 text-sm text-center py-8">No activities yet — <Link to="/upload" className="text-blue-400 hover:underline">import some data</Link></p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -482,6 +485,7 @@ export default function DashboardPage() {
|
||||
sleep_awake_s: latest.sleep_awake_s ?? null,
|
||||
sleep_score: pick('sleep_score'),
|
||||
hrv_nightly_avg: pick('hrv_nightly_avg'),
|
||||
hrv_weekly_avg: pick('hrv_weekly_avg'),
|
||||
hrv_status: pick('hrv_status'),
|
||||
steps: pick('steps'),
|
||||
vo2max: pick('vo2max'),
|
||||
@@ -513,6 +517,8 @@ export default function DashboardPage() {
|
||||
})
|
||||
|
||||
// ── Layout state ──────────────────────────────────────────────────────────
|
||||
const unit = useUnit()
|
||||
const isMobile = useIsMobile()
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [layout, setLayout] = useState(() => buildLayout(null))
|
||||
@@ -554,20 +560,29 @@ export default function DashboardPage() {
|
||||
}
|
||||
const removeWidget = (id) => { const next = layout.filter(l => l.i !== id); setLayout(next); persist(next) }
|
||||
|
||||
// Editing is desktop-only; drop out of edit mode if the viewport shrinks mid-edit.
|
||||
useEffect(() => {
|
||||
if (isMobile && editMode) { setEditMode(false); setAddOpen(false) }
|
||||
}, [isMobile])
|
||||
|
||||
const finishEditing = () => { persist(layout); setEditMode(false); setAddOpen(false) }
|
||||
const resetLayout = () => { const def = attachMins(DEFAULT_LAYOUT); setLayout(def); persist(def) }
|
||||
|
||||
const renderWidget = (id) => {
|
||||
if (STAT_DEFS[id]) {
|
||||
const d = STAT_DEFS[id]
|
||||
return <StatCard label={d.label} accent={d.accent} value={d.val(health, ytdStats)}
|
||||
// VO2 max is coloured dynamically by its current rating category.
|
||||
const color = id === 'stat_vo2max'
|
||||
? vo2Color(health.vo2max, profile?.birth_year, profile?.biological_sex)
|
||||
: undefined
|
||||
return <StatCard label={d.label} accent={d.accent} color={color} value={d.val(health, ytdStats, unit)}
|
||||
sub={typeof d.sub === 'function' ? d.sub(health) : d.sub} />
|
||||
}
|
||||
switch (id) {
|
||||
case 'weekly': return <WeeklyChart activities={allActivities} />
|
||||
case 'bodyBattery': return <BodyBatteryToday bb={intraday?.body_battery} hires={intraday?.body_battery_hires} sleepStart={health.sleep_start} sleepEnd={health.sleep_end} />
|
||||
case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} />
|
||||
case 'sleepDetail': return <SleepDetail health={health} />
|
||||
case 'bodyBattery': return <BodyBatteryToday bb={intraday?.body_battery} hires={intraday?.body_battery_hires} sleepStart={health.sleep_start} sleepEnd={health.sleep_end} activities={allActivities} />
|
||||
case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} profile={profile} />
|
||||
case 'sleepDetail': return <SleepDetail health={health} sleepStages={intraday?.sleep_stages} />
|
||||
case 'weight': return <WeightMini recentHealth={recentHealth} />
|
||||
case 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
|
||||
case 'recent': return <RecentActivities activities={recentActivities} />
|
||||
@@ -579,8 +594,40 @@ export default function DashboardPage() {
|
||||
const presentIds = new Set(layout.map(l => l.i))
|
||||
const available = Object.keys(WIDGETS).filter(id => !presentIds.has(id))
|
||||
|
||||
// Single-column stack for phones: saved desktop layout read top-to-bottom,
|
||||
// left-to-right; consecutive stat cards pair up into a 2-column grid.
|
||||
const renderMobileStack = () => {
|
||||
const sorted = [...layout].filter(l => WIDGETS[l.i]).sort((a, b) => a.y - b.y || a.x - b.x)
|
||||
const groups = []
|
||||
for (const l of sorted) {
|
||||
const last = groups[groups.length - 1]
|
||||
if (STAT_DEFS[l.i] && last?.stats) last.items.push(l)
|
||||
else groups.push({ stats: !!STAT_DEFS[l.i], items: [l] })
|
||||
}
|
||||
// Content-driven widgets size themselves; chart widgets need the explicit
|
||||
// height the grid normally provides (rowHeight=80, margin=16) or their
|
||||
// ResponsiveContainers collapse.
|
||||
const autoHeight = new Set(['sleepDetail', 'prs'])
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{groups.map((g, idx) =>
|
||||
g.stats ? (
|
||||
<div key={idx} className="grid grid-cols-2 gap-3">
|
||||
{g.items.map(l => <div key={l.i}>{renderWidget(l.i)}</div>)}
|
||||
</div>
|
||||
) : (
|
||||
<div key={g.items[0].i}
|
||||
style={autoHeight.has(g.items[0].i) ? undefined : { height: g.items[0].h * 80 + (g.items[0].h - 1) * 16 }}>
|
||||
{renderWidget(g.items[0].i)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -607,6 +654,7 @@ export default function DashboardPage() {
|
||||
{editMode && (
|
||||
<button onClick={resetLayout} className="text-xs text-gray-400 hover:text-white transition-colors">Reset layout</button>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={() => (editMode ? finishEditing() : setEditMode(true))}
|
||||
className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${
|
||||
@@ -614,6 +662,7 @@ export default function DashboardPage() {
|
||||
}`}>
|
||||
{editMode ? '✓ Done' : '✎ Edit dashboard'}
|
||||
</button>
|
||||
)}
|
||||
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -622,6 +671,7 @@ export default function DashboardPage() {
|
||||
<p className="text-xs text-gray-500 mb-3">Drag to move, drag a corner to resize, or remove a widget with ✕. Add widgets from the menu. Changes save automatically.</p>
|
||||
)}
|
||||
|
||||
{isMobile ? renderMobileStack() : (
|
||||
<Grid
|
||||
className="layout"
|
||||
layout={layout}
|
||||
@@ -647,6 +697,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+141
-316
@@ -1,13 +1,16 @@
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
||||
import {
|
||||
AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine, ReferenceArea,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
||||
AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
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'
|
||||
import { formatSleep } from '../utils/format'
|
||||
import HrvBadge from '../components/ui/HrvBadge'
|
||||
import { VO2_CATEGORIES, getVo2Category, vo2Thresholds, vo2Color } from '../utils/vo2'
|
||||
import SleepHypnogram from '../components/health/SleepHypnogram'
|
||||
import BodyBatteryChart from '../components/health/BodyBatteryChart'
|
||||
|
||||
const RANGES = [
|
||||
{ label: '1W', days: 7 },
|
||||
@@ -22,42 +25,6 @@ const RANGES = [
|
||||
|
||||
// ── VO2 Max gauge ────────────────────────────────────────────────────────────
|
||||
|
||||
// Garmin/Cooper Institute VO2 max thresholds
|
||||
// [maxAge, [fair_min, good_min, excellent_min, superior_min]]
|
||||
// value < fair_min → Poor; >= superior_min → Superior
|
||||
const VO2_MALE = [
|
||||
[29, [41.7, 45.4, 51.1, 55.4]],
|
||||
[39, [40.5, 44.0, 48.3, 54.0]],
|
||||
[49, [38.5, 42.4, 46.4, 52.5]],
|
||||
[59, [35.6, 39.2, 43.4, 48.9]],
|
||||
[69, [32.3, 35.5, 39.5, 45.7]],
|
||||
[Infinity, [29.4, 32.3, 36.7, 42.1]],
|
||||
]
|
||||
const VO2_FEMALE = [
|
||||
[29, [36.1, 39.5, 43.9, 49.6]],
|
||||
[39, [34.4, 37.8, 42.4, 47.4]],
|
||||
[49, [33.0, 36.3, 39.7, 45.3]],
|
||||
[59, [30.1, 33.0, 36.7, 41.1]],
|
||||
[69, [27.5, 30.0, 33.0, 37.8]],
|
||||
[Infinity, [25.9, 28.1, 30.9, 36.7]],
|
||||
]
|
||||
const VO2_CATEGORIES = [
|
||||
{ label: 'Poor', color: '#ef4444' },
|
||||
{ label: 'Fair', color: '#f97316' },
|
||||
{ label: 'Good', color: '#22c55e' },
|
||||
{ label: 'Excellent', color: '#3b82f6' },
|
||||
{ label: 'Superior', color: '#a855f7' },
|
||||
]
|
||||
|
||||
function getVo2Category(value, age, sex) {
|
||||
const table = sex === 'female' ? VO2_FEMALE : VO2_MALE
|
||||
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
||||
const thresholds = row[1]
|
||||
// thresholds are lower-bounds: count how many the value meets or exceeds
|
||||
const idx = thresholds.reduce((n, t) => value >= t ? n + 1 : n, 0)
|
||||
return VO2_CATEGORIES[idx]
|
||||
}
|
||||
|
||||
function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
|
||||
const MIN = 30, MAX = 70
|
||||
// cx/cy = centre of the semicircle; arc goes left→top→right (sweep=1, clockwise in SVG)
|
||||
@@ -83,9 +50,7 @@ function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
|
||||
}
|
||||
|
||||
// ACSM category boundaries for this user's age/sex
|
||||
const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE
|
||||
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
||||
const thresholds = row[1]
|
||||
const thresholds = vo2Thresholds(age, biologicalSex)
|
||||
const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands
|
||||
|
||||
const cat = value != null ? getVo2Category(value, age, biologicalSex) : null
|
||||
@@ -161,8 +126,8 @@ function IntradayHrChart({ values }) {
|
||||
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="grad-intraday-hr" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
@@ -173,7 +138,7 @@ function IntradayHrChart({ values }) {
|
||||
<Tooltip contentStyle={tooltipStyle}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
formatter={v => [`${Math.round(v)} bpm`, 'HR']} />
|
||||
<Area type="monotone" dataKey="hr" stroke="#f43f5e" strokeWidth={1.5}
|
||||
<Area type="monotone" dataKey="hr" stroke="#ef4444" strokeWidth={1.5}
|
||||
fill="url(#grad-intraday-hr)" dot={false} isAnimationActive={false} connectNulls={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -182,214 +147,14 @@ function IntradayHrChart({ values }) {
|
||||
|
||||
// ── Body Battery ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ActivityRefLabel({ viewBox, icon }) {
|
||||
if (!viewBox) return null
|
||||
const { x, y, width = 0 } = viewBox
|
||||
return (
|
||||
<text x={x + width / 2} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
|
||||
{icon}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) {
|
||||
if (!bb) return null
|
||||
const { charged, drained, start_level, end_level } = bb
|
||||
if (!hiresValues?.length && !bb.values?.length && end_level == null) return null
|
||||
|
||||
const rawData = hiresValues?.length
|
||||
? hiresValues.map(([ts, level]) => ({ t: ts, level }))
|
||||
: (bb.values || []).map(([ts, level]) => ({ t: ts, level }))
|
||||
|
||||
if (!rawData.length) return null
|
||||
|
||||
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
|
||||
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
|
||||
|
||||
const chartData = rawData.map((d, i) => ({
|
||||
...d,
|
||||
type: inferBBType(d.t, d.level, i > 0 ? rawData[i - 1].level : null, sleepStartMs, sleepEndMs),
|
||||
}))
|
||||
|
||||
const presentTypes = [...new Set(chartData.map(d => d.type))]
|
||||
const levelColor = bbLevelColor(end_level)
|
||||
const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null
|
||||
|
||||
// The X axis is categorical (band scale), so overlays must use values that
|
||||
// exist in the data — snap activity start/end to the nearest sample.
|
||||
const nearestT = (ms) => {
|
||||
let best = null, bd = Infinity
|
||||
for (const d of chartData) { const dd = Math.abs(d.t - ms); if (dd < bd) { bd = dd; best = d.t } }
|
||||
return best
|
||||
}
|
||||
|
||||
// Health-page Body Battery panel — the shared BodyBatteryChart in this page's
|
||||
// card frame. Uses a fixed chart height (the panel has no intrinsic height).
|
||||
function BodyBatteryPanel({ bb, hiresValues, sleepStart, sleepEnd, activities }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3>
|
||||
|
||||
<div className="flex items-baseline gap-3 flex-wrap mb-3">
|
||||
{maxLevel != null && (
|
||||
<span className="text-3xl font-bold" style={{ color: bbLevelColor(maxLevel) }}>{Math.round(maxLevel)}</span>
|
||||
)}
|
||||
{charged != null && (
|
||||
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
||||
)}
|
||||
{drained != null && (
|
||||
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
||||
)}
|
||||
{end_level != null && (
|
||||
<span className="text-xs text-gray-500">now {Math.round(end_level)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<ResponsiveContainer width="100%" height={100}>
|
||||
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 28 }} barCategoryGap={0}>
|
||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||
tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} />
|
||||
<Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
formatter={v => [`${Math.round(v)}%`, 'Battery']} />
|
||||
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
||||
{chartData.map((d, i) => (
|
||||
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
||||
))}
|
||||
</Bar>
|
||||
{(activities || []).map(a => {
|
||||
const start = new Date(a.start_time).getTime()
|
||||
const end = a.duration_s ? start + a.duration_s * 1000 : start
|
||||
const x1 = nearestT(start), x2 = nearestT(end)
|
||||
if (x1 == null || x2 == null) return null
|
||||
return (
|
||||
<ReferenceArea
|
||||
key={`area-${a.id}`}
|
||||
x1={x1}
|
||||
x2={x2}
|
||||
fill="rgba(255,255,255,0.16)"
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
strokeWidth={1}
|
||||
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{presentTypes.map(type => (
|
||||
<div key={type} className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
|
||||
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Proper sleep hypnogram: 4 horizontal lanes (Awake/REM/Light/Deep), time on X axis
|
||||
const SLEEP_LANE_ORDER = [1, 4, 2, 3] // top→bottom: awake, rem, light, deep
|
||||
const SLEEP_STAGE_COLOR = { 0: '#6b7280', 1: '#eab308', 2: '#a78bfa', 3: '#6366f1', 4: '#7c3aed' }
|
||||
const SLEEP_STAGE_LABEL = { 1: 'Awake', 2: 'Light', 3: 'Deep', 4: 'REM' }
|
||||
const LANE_H = 15
|
||||
|
||||
const fmtClock = (ms) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
|
||||
const wrapRef = useRef(null)
|
||||
const [tip, setTip] = useState(null)
|
||||
if (!sleepStart || !sleepEnd || !stages?.length) return null
|
||||
const startMs = new Date(sleepStart).getTime()
|
||||
const endMs = new Date(sleepEnd).getTime()
|
||||
const windowMs = endMs - startMs
|
||||
if (windowMs <= 0) return null
|
||||
|
||||
// Build segments per lane (keep each segment's real start/end for the tooltip)
|
||||
const segsByLane = {}
|
||||
SLEEP_LANE_ORDER.forEach(lv => { segsByLane[lv] = [] })
|
||||
stages.forEach(([tsMs, level], i) => {
|
||||
if (!(level in segsByLane)) return
|
||||
const nextTs = i + 1 < stages.length ? stages[i + 1][0] : endMs
|
||||
const left = Math.max(0, (tsMs - startMs) / windowMs * 100)
|
||||
const right = Math.min(100, (nextTs - startMs) / windowMs * 100)
|
||||
const w = right - left
|
||||
if (w > 0) segsByLane[level].push({ left, w, level, startMs: tsMs, endMs: nextTs })
|
||||
})
|
||||
|
||||
const showTip = (seg, e) => {
|
||||
const rect = wrapRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
setTip({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
level: seg.level,
|
||||
range: `${fmtClock(seg.startMs)}–${fmtClock(seg.endMs)}`,
|
||||
mins: Math.max(1, Math.round((seg.endMs - seg.startMs) / 60000)),
|
||||
})
|
||||
}
|
||||
|
||||
// Hour ticks
|
||||
const sh = new Date(startMs); sh.setMinutes(0, 0, 0); sh.setHours(sh.getHours() + 1)
|
||||
const ticks = []
|
||||
for (let t = sh.getTime(); t < endMs; t += 3600000) {
|
||||
const pct = (t - startMs) / windowMs * 100
|
||||
if (pct >= 0 && pct <= 100)
|
||||
ticks.push({ pct, label: new Date(t).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<div ref={wrapRef} className="relative" onMouseLeave={() => setTip(null)}>
|
||||
<div className="space-y-px">
|
||||
{SLEEP_LANE_ORDER.map(level => (
|
||||
<div key={level} className="relative flex items-center">
|
||||
<span className="absolute right-full pr-1.5 text-gray-500 whitespace-nowrap select-none"
|
||||
style={{ fontSize: 10 }}>
|
||||
{SLEEP_STAGE_LABEL[level]}
|
||||
</span>
|
||||
<div className="relative flex-1 rounded-sm overflow-hidden bg-gray-800/50" style={{ height: LANE_H }}>
|
||||
{segsByLane[level].map((seg, i) => (
|
||||
<div key={i} className="absolute top-0 h-full cursor-pointer"
|
||||
style={{ left: `${seg.left}%`, width: `${seg.w}%`, backgroundColor: SLEEP_STAGE_COLOR[level] }}
|
||||
onMouseEnter={(e) => showTip(seg, e)}
|
||||
onMouseMove={(e) => showTip(seg, e)} />
|
||||
))}
|
||||
{ticks.map((t, i) => (
|
||||
<div key={i} className="absolute top-0 bottom-0 w-px bg-black/20 pointer-events-none"
|
||||
style={{ left: `${t.pct}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tip && (
|
||||
<div className="absolute z-20 pointer-events-none px-2 py-1 rounded-md bg-gray-900/95 border border-gray-700 shadow-lg whitespace-nowrap flex items-center gap-1.5"
|
||||
style={{ left: tip.x, top: tip.y - 10, transform: 'translate(-50%, -100%)', fontSize: 11 }}>
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ backgroundColor: SLEEP_STAGE_COLOR[tip.level] }} />
|
||||
<span className="text-white font-medium">{SLEEP_STAGE_LABEL[tip.level]}</span>
|
||||
<span className="text-gray-400">{tip.range}</span>
|
||||
<span className="text-gray-500">· {tip.mins}m</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-4 mt-1 ml-0">
|
||||
<span className="absolute left-0 text-gray-500" style={{ fontSize: 10 }}>
|
||||
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{ticks.map((t, i) => (
|
||||
<span key={i} className="absolute text-gray-600"
|
||||
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)', fontSize: 10 }}>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="absolute right-0 text-gray-500" style={{ fontSize: 10 }}>
|
||||
{new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<BodyBatteryChart
|
||||
bb={bb} hires={hiresValues} sleepStart={sleepStart} sleepEnd={sleepEnd} activities={activities} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -414,18 +179,6 @@ function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HrvBadge({ status }) {
|
||||
if (!status) return null
|
||||
const palette = {
|
||||
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
||||
unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30',
|
||||
low: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
||||
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||
}
|
||||
const cls = palette[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30'
|
||||
return <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
function NavArrow({ onClick, disabled, children }) {
|
||||
return (
|
||||
<button
|
||||
@@ -458,10 +211,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
||||
: day.avg_stress < 25 ? 'Restful'
|
||||
: day.avg_stress < 50 ? 'Low'
|
||||
: day.avg_stress < 75 ? 'Medium' : 'High'
|
||||
const stressColor = !day.avg_stress ? 'text-white'
|
||||
: day.avg_stress < 25 ? 'text-green-400'
|
||||
: day.avg_stress < 50 ? 'text-yellow-400'
|
||||
: day.avg_stress < 75 ? 'text-orange-400' : 'text-red-400'
|
||||
const stressColor = day.avg_stress ? 'text-orange-400' : 'text-white'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -483,13 +233,13 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
||||
{day.sleep_score != null && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-indigo-400/30 bg-indigo-400/10 text-indigo-300">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-violet-400/30 bg-violet-400/10 text-violet-300">
|
||||
Score {Math.round(day.sleep_score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end gap-3">
|
||||
<span className="text-4xl font-bold text-white tracking-tight">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<span className="text-4xl font-bold text-violet-400 tracking-tight">
|
||||
{formatSleep(day.sleep_duration_s)}
|
||||
</span>
|
||||
{day.sleep_start && day.sleep_end && (
|
||||
@@ -538,7 +288,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-3xl font-bold text-rose-400">
|
||||
<span className="text-3xl font-bold text-red-400">
|
||||
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">bpm</span>
|
||||
@@ -558,7 +308,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
||||
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-3xl font-bold text-violet-400">
|
||||
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
|
||||
{(day.hrv_weekly_avg ?? day.hrv_nightly_avg) ? Math.round(day.hrv_weekly_avg ?? day.hrv_nightly_avg) : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">ms</span>
|
||||
</div>
|
||||
@@ -567,7 +317,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-xl font-semibold text-orange-400">
|
||||
<span className="text-xl font-semibold text-red-400">
|
||||
{day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'}
|
||||
</span>
|
||||
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max</span>}
|
||||
@@ -576,7 +326,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-xl font-semibold text-emerald-400">
|
||||
<span className="text-xl font-semibold text-blue-400">
|
||||
{snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'}
|
||||
</span>
|
||||
{snapshotWeight && <span className="text-xs text-gray-500">kg</span>}
|
||||
@@ -606,7 +356,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} activities={activities} />
|
||||
<BodyBatteryPanel bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} activities={activities} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -688,16 +438,58 @@ const statusDot = (statusKey) => (props) => {
|
||||
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
|
||||
}
|
||||
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey }) {
|
||||
// Like statusDot, but also draws a small base-coloured dot on every reading that
|
||||
// has no status colour — so a line-only chart (e.g. HRV) shows every data point.
|
||||
const statusDotWithBase = (statusKey, baseColor) => (props) => {
|
||||
const { cx, cy, payload, value } = props
|
||||
if (cx == null || cy == null) return null
|
||||
const color = STATUS_DOT_COLORS[String(payload?.[statusKey] || '').toLowerCase()]
|
||||
if (color) return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
|
||||
if (value == null) return null
|
||||
return <circle cx={cx} cy={cy} r={2.5} fill={baseColor} />
|
||||
}
|
||||
|
||||
// Custom tooltip: collapses the dashed gap-bridging Line and the solid Area
|
||||
// (same dataKey) into one row, and never shows the helper baseline band.
|
||||
function ChartTooltip({ active, payload, label, formatter }) {
|
||||
if (!active || !payload?.length) return null
|
||||
const seen = new Set()
|
||||
const rows = []
|
||||
for (const p of payload) {
|
||||
if (p.dataKey === '__band' || p.value == null || seen.has(p.dataKey)) continue
|
||||
seen.add(p.dataKey)
|
||||
rows.push(p)
|
||||
}
|
||||
if (!rows.length) return null
|
||||
return (
|
||||
<div style={{ ...tooltipStyle, padding: '6px 10px' }}>
|
||||
<div style={{ color: '#9ca3af', marginBottom: 2 }}>{format(new Date(label), 'MMM d, yyyy')}</div>
|
||||
{rows.map(p => (
|
||||
<div key={p.dataKey} style={{ color: '#fff' }}>{formatter ? formatter(p.value) : p.value?.toFixed(1)}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 170, selectedDate, onDayClick, connectNulls = false, showDots = false, fillArea = true, domain, referenceLines, statusDotKey, bandLowKey, bandHighKey, bandColor = '#9ca3af' }) {
|
||||
const vals = data.filter(d => d[dataKey] != null)
|
||||
if (!vals.length) return (
|
||||
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
||||
)
|
||||
// Range band (e.g. Garmin's HRV baseline): Recharts renders an Area as a band
|
||||
// when its dataKey resolves to a [low, high] pair.
|
||||
const hasBand = bandLowKey && bandHighKey
|
||||
const chartData = hasBand
|
||||
? data.map(d => ({
|
||||
...d,
|
||||
__band: (d[bandLowKey] != null && d[bandHighKey] != null) ? [d[bandLowKey], d[bandHighKey]] : null,
|
||||
}))
|
||||
: data
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
|
||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||
onClick={evt => {
|
||||
const p = evt?.activePayload?.[0]?.payload
|
||||
@@ -715,22 +507,32 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
|
||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||
tickFormatter={formatter} domain={domain} />
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
||||
<Tooltip content={<ChartTooltip formatter={formatter} />} />
|
||||
{hasBand && (
|
||||
<Area type="monotone" dataKey="__band" stroke="none" fill={bandColor} fillOpacity={0.18}
|
||||
connectNulls isAnimationActive={false} legendType="none" activeDot={false} />
|
||||
)}
|
||||
{selectedDate && (
|
||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||
)}
|
||||
{(referenceLines || []).map((rl, i) => (
|
||||
<ReferenceLine key={i} {...rl} />
|
||||
))}
|
||||
{/* Dashed line bridging gaps (no data). Drawn first; the solid area below
|
||||
{/* Dashed line bridging gaps (no data). Drawn first; the solid series below
|
||||
covers it wherever real data exists, leaving only gaps shown dashed. */}
|
||||
<Line type="monotone" dataKey={dataKey} stroke={color} strokeWidth={1.5}
|
||||
strokeDasharray="4 4" dot={false} connectNulls isAnimationActive={false} legendType="none" />
|
||||
{fillArea ? (
|
||||
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||
fill={`url(#grad-${dataKey})`}
|
||||
dot={statusDotKey ? statusDot(statusDotKey) : (showDots ? { fill: color, r: 3, strokeWidth: 0 } : false)}
|
||||
connectNulls={false} isAnimationActive={false} />
|
||||
) : (
|
||||
// Line-only (no gradient fill) — e.g. HRV, so the grey baseline band shows through.
|
||||
<Line type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||
dot={statusDotKey ? statusDotWithBase(statusDotKey, color) : (showDots ? { fill: color, r: 3, strokeWidth: 0 } : false)}
|
||||
connectNulls={false} isAnimationActive={false} legendType="none" />
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
@@ -753,10 +555,10 @@ function SleepChart({ data, selectedDate, onDayClick }) {
|
||||
.filter(t => t > 0)
|
||||
const avgSleep = totals.length ? +(totals.reduce((a, b) => a + b, 0) / totals.length).toFixed(1) : null
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<ResponsiveContainer width="100%" height={170}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 44, bottom: 4, left: 0 }}
|
||||
margin={{ top: 4, right: 44, bottom: 0, left: 0 }}
|
||||
barSize={6}
|
||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||
onClick={evt => {
|
||||
@@ -836,8 +638,11 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
||||
const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
|
||||
const minKg = Math.min(...withWeight.map(d => d.weight_kg))
|
||||
const goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null
|
||||
const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent
|
||||
const yMin = Math.max(0, Math.floor(toU(Math.max(0, minKg - 20)))) // lowest weight − 20 kg equivalent
|
||||
// ±5 kg around the displayed weight range; keep the goal line in view if set.
|
||||
const lowKg = goalKg != null ? Math.min(minKg, goalKg) : minKg
|
||||
const highKg = goalKg != null ? Math.max(maxKg, goalKg) : maxKg
|
||||
const yMax = Math.ceil(toU(highKg + 5))
|
||||
const yMin = Math.max(0, Math.floor(toU(Math.max(0, lowKg - 5))))
|
||||
const fmtVal = (v) => (imperial ? `${fmtStLb(v)} (${Math.round(v)} lb)` : `${v.toFixed(1)} kg`)
|
||||
|
||||
return (
|
||||
@@ -845,8 +650,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
||||
<ResponsiveContainer width="100%" height={170}>
|
||||
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
|
||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||
onClick={evt => {
|
||||
const p = evt?.activePayload?.[0]?.payload
|
||||
@@ -854,8 +659,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
||||
}}>
|
||||
<defs>
|
||||
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
@@ -872,8 +677,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
||||
<ReferenceLine y={goalU} stroke="#22c55e" strokeDasharray="5 3" strokeWidth={1.5}
|
||||
label={{ value: `Goal ${imperial ? fmtStLb(goalU) : `${goalU} kg`}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
|
||||
)}
|
||||
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
|
||||
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
|
||||
<Area type="monotone" dataKey="w" stroke="#3b82f6" strokeWidth={2}
|
||||
fill="url(#grad-weight)" dot={{ fill: '#3b82f6', r: 3, strokeWidth: 0 }}
|
||||
connectNulls isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -922,6 +727,21 @@ export default function HealthPage() {
|
||||
})
|
||||
const metrics = rawMetrics || []
|
||||
|
||||
// HRV Y-axis: span the baseline band ±15 ms (low baseline − 15 → high baseline + 15).
|
||||
const hrvDomain = useMemo(() => {
|
||||
const lows = metrics.map(d => d.hrv_baseline_low).filter(v => v != null)
|
||||
const ups = metrics.map(d => d.hrv_baseline_upper).filter(v => v != null)
|
||||
if (!lows.length || !ups.length) return ['auto', 'auto']
|
||||
return [Math.floor(Math.min(...lows) - 15), Math.ceil(Math.max(...ups) + 15)]
|
||||
}, [metrics])
|
||||
|
||||
// VO2 Max Y-axis: displayed min/max ±5 points.
|
||||
const vo2Domain = useMemo(() => {
|
||||
const vals = metrics.map(d => d.vo2max).filter(v => v != null)
|
||||
if (!vals.length) return [30, 70]
|
||||
return [Math.floor(Math.min(...vals) - 5), Math.ceil(Math.max(...vals) + 5)]
|
||||
}, [metrics])
|
||||
|
||||
// Snapshot navigation: newest-first sorted list of all available days
|
||||
const allDaysSorted = useMemo(
|
||||
() => (allDays || []).slice().sort((a, b) => b.date.localeCompare(a.date)),
|
||||
@@ -956,6 +776,12 @@ export default function HealthPage() {
|
||||
return found ? found.vo2max : null
|
||||
}, [allDaysSorted])
|
||||
|
||||
// Colour VO2 max (gauge + trend) by the current rating's category.
|
||||
const vo2TrendColor = useMemo(
|
||||
() => vo2Color(latestVo2max, profile?.birth_year, profile?.biological_sex),
|
||||
[latestVo2max, profile],
|
||||
)
|
||||
|
||||
// Weight for the snapshot: the selected day's, or the most recent earlier reading.
|
||||
const snapshotWeight = useMemo(() => {
|
||||
if (!selectedDay) return null
|
||||
@@ -995,7 +821,7 @@ export default function HealthPage() {
|
||||
const selDateForCharts = selectedDay?.date
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
<div className="p-4 md:p-6 space-y-6 md:space-y-8">
|
||||
<h1 className="text-2xl font-bold text-white">Health</h1>
|
||||
|
||||
<DailySnapshot
|
||||
@@ -1024,7 +850,7 @@ export default function HealthPage() {
|
||||
<h2 className="text-base font-semibold text-gray-300">Trends</h2>
|
||||
<p className="text-xs text-gray-600">Click any point to load that day above</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{RANGES.map(({ label, days }, i) => {
|
||||
const disabled = i > maxEnabledRangeIdx
|
||||
return (
|
||||
@@ -1053,7 +879,7 @@ export default function HealthPage() {
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
|
||||
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
|
||||
<MetricChart data={metrics} dataKey="resting_hr" color="#ef4444"
|
||||
formatter={v => Math.round(v)}
|
||||
domain={[0, 200]}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
@@ -1061,37 +887,36 @@ export default function HealthPage() {
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-300">HRV (nightly avg)</h3>
|
||||
<h3 className="text-sm font-medium text-gray-300">HRV (overnight avg)</h3>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} /> Balanced</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#f97316' }} /> Unbalanced</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#ef4444' }} /> Low</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-2 rounded-sm" style={{ background: '#9ca3af', opacity: 0.5 }} /> Baseline</span>
|
||||
</div>
|
||||
</div>
|
||||
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
||||
<MetricChart data={metrics} dataKey="hrv_weekly_avg" color="#8b5cf6"
|
||||
formatter={v => `${Math.round(v)} ms`}
|
||||
fillArea={false} domain={hrvDomain}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||
statusDotKey="hrv_status"
|
||||
referenceLines={[
|
||||
{ y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
|
||||
{ y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
|
||||
]}
|
||||
bandLowKey="hrv_baseline_low" bandHighKey="hrv_baseline_upper" bandColor="#9ca3af"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep</h3>
|
||||
<SleepChart data={metrics}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
<div className="flex gap-4 mt-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
{[['Deep','#6366f1'],['REM','#7c3aed'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
|
||||
<div key={l} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
||||
<span className="text-xs text-gray-400">{l}</span>
|
||||
</div>
|
||||
<span key={l} className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-sm" style={{ backgroundColor: c }} />{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SleepChart data={metrics}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
</div>
|
||||
|
||||
{metrics.some(d => d.sleep_score != null) && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
@@ -1117,10 +942,10 @@ export default function HealthPage() {
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<ResponsiveContainer width="100%" height={170}>
|
||||
<BarChart
|
||||
data={metrics}
|
||||
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
||||
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
|
||||
barSize={6}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={evt => {
|
||||
@@ -1144,7 +969,7 @@ export default function HealthPage() {
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
|
||||
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
|
||||
<MetricChart data={metrics} dataKey="avg_stress" color="#f97316"
|
||||
formatter={v => Math.round(v)}
|
||||
domain={[0, 100]}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
@@ -1152,7 +977,7 @@ export default function HealthPage() {
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate</h3>
|
||||
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
||||
<MetricChart data={metrics} dataKey="avg_hr_day" color="#ef4444"
|
||||
formatter={v => Math.round(v)}
|
||||
domain={[0, 200]}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
@@ -1172,9 +997,9 @@ export default function HealthPage() {
|
||||
{metrics.some(d => d.vo2max) && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
||||
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
|
||||
<MetricChart data={metrics} dataKey="vo2max" color={vo2TrendColor}
|
||||
formatter={v => v.toFixed(1)}
|
||||
domain={[30, 70]}
|
||||
domain={vo2Domain}
|
||||
connectNulls showDots
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
|
||||
<div className="min-h-dvh bg-gray-950 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { useAuthStore } from '../hooks/useAuth'
|
||||
import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync'
|
||||
import { useMapSettingsStore } from '../hooks/useMapSettings'
|
||||
import { MAP_PROVIDERS } from '../utils/mapTiles'
|
||||
import RouteTileMap from '../components/ui/RouteTileMap'
|
||||
|
||||
// Human-friendly description of the automatic sync cadence, e.g. "every 30 min",
|
||||
// "hourly", "every 2 h". Driven by the backend's configured interval.
|
||||
@@ -40,6 +43,15 @@ function Input({ type = 'text', value, onChange, placeholder, min, max }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Select({ value, onChange, children }) {
|
||||
return (
|
||||
<select value={value} onChange={onChange}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
@@ -175,6 +187,65 @@ export default function ProfilePage() {
|
||||
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
|
||||
},
|
||||
})
|
||||
// Strava sync
|
||||
const { data: stravaConfig, refetch: refetchStrava } = useQuery({
|
||||
queryKey: ['strava-config'],
|
||||
queryFn: () => api.get('/strava-sync/config').then(r => r.data),
|
||||
// Poll while a sync is running so the status text stays live.
|
||||
refetchInterval: q => {
|
||||
const s = q.state.data?.last_sync_status || ''
|
||||
const running = s && !/^(OK|Error|Auth error|Connected|Cancelled|Lookback)/.test(s)
|
||||
return running ? 3000 : false
|
||||
},
|
||||
})
|
||||
const [stForm, setStForm] = useState({ sync_enabled: true, sync_lookback_days: '30' })
|
||||
const [stSaved, setStSaved] = useState(false)
|
||||
const stFormLoaded = useRef(false)
|
||||
useEffect(() => {
|
||||
if (stravaConfig?.connected && !stFormLoaded.current) {
|
||||
stFormLoaded.current = true
|
||||
setStForm({
|
||||
sync_enabled: stravaConfig.sync_enabled,
|
||||
sync_lookback_days: String(stravaConfig.sync_lookback_days ?? 30),
|
||||
})
|
||||
} else if (!stravaConfig?.connected) {
|
||||
stFormLoaded.current = false
|
||||
}
|
||||
}, [stravaConfig])
|
||||
// OAuth return banner (?strava=connected|error|scope), then strip the query.
|
||||
const [stReturn, setStReturn] = useState('')
|
||||
useEffect(() => {
|
||||
const p = new URLSearchParams(window.location.search)
|
||||
const s = p.get('strava')
|
||||
if (s) {
|
||||
setStReturn(s)
|
||||
refetchStrava()
|
||||
p.delete('strava')
|
||||
const qs = p.toString()
|
||||
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
||||
}
|
||||
}, [])
|
||||
const connectStrava = useMutation({
|
||||
mutationFn: () => api.get('/strava-sync/authorize').then(r => r.data),
|
||||
onSuccess: d => { if (d?.url) window.location.href = d.url },
|
||||
})
|
||||
const saveStrava = useMutation({
|
||||
mutationFn: data => api.put('/strava-sync/config', data).then(r => r.data),
|
||||
onSuccess: () => { refetchStrava(); setStSaved(true); setTimeout(() => setStSaved(false), 3000) },
|
||||
})
|
||||
const triggerStrava = useMutation({
|
||||
mutationFn: () => api.post('/strava-sync/trigger'),
|
||||
onSuccess: () => setTimeout(refetchStrava, 1000),
|
||||
})
|
||||
const deleteStrava = useMutation({
|
||||
mutationFn: () => api.delete('/strava-sync/config'),
|
||||
onSuccess: () => { refetchStrava(); setStForm({ sync_enabled: true, sync_lookback_days: '30' }) },
|
||||
})
|
||||
const stravaSyncing = (() => {
|
||||
const s = stravaConfig?.last_sync_status || ''
|
||||
return !!s && !/^(OK|Error|Auth error|Connected|Cancelled|Lookback)/.test(s)
|
||||
})()
|
||||
|
||||
// PocketID config
|
||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
|
||||
const [pidSaved, setPidSaved] = useState(false)
|
||||
@@ -186,10 +257,22 @@ export default function ProfilePage() {
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
|
||||
})
|
||||
|
||||
// Global map tiles preference (provider / style / API keys) — stored in
|
||||
// localStorage, used by every map in the app.
|
||||
const mapProvider = useMapSettingsStore(s => s.provider)
|
||||
const mapStyle = useMapSettingsStore(s => s.style)
|
||||
const mapKeys = useMapSettingsStore(s => s.keys)
|
||||
const setMapProvider = useMapSettingsStore(s => s.setProvider)
|
||||
const setMapStyle = useMapSettingsStore(s => s.setStyle)
|
||||
const setMapKey = useMapSettingsStore(s => s.setKey)
|
||||
const providerDef = MAP_PROVIDERS[mapProvider] || MAP_PROVIDERS.thunderforest
|
||||
// A sample track so the preview below reflects the selected tiles live.
|
||||
const SAMPLE_POLYLINE = 'mniyHpouMm@kBeAoCq@_BqAyCk@iAa@s@m@_AcAuAaAcAyAuAa@]'
|
||||
|
||||
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl space-y-6">
|
||||
<div className="p-4 md:p-6 max-w-2xl space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
|
||||
|
||||
{/* HR & Measurements */}
|
||||
@@ -205,7 +288,7 @@ export default function ProfilePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
|
||||
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
||||
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
||||
@@ -235,7 +318,7 @@ export default function ProfilePage() {
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-3 border-t border-gray-800">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-3 border-t border-gray-800">
|
||||
<Field label="Goal weight (kg)" hint="Shown as a target line on the weight trend chart">
|
||||
<Input type="number" value={hrForm.goal_weight_kg} placeholder="e.g. 72" min={20} max={500}
|
||||
onChange={e => setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} />
|
||||
@@ -243,7 +326,7 @@ export default function ProfilePage() {
|
||||
{healthSummary?.latest?.weight_kg && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Current weight (from Garmin)</p>
|
||||
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
|
||||
<span className="text-lg font-semibold text-blue-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -264,6 +347,55 @@ export default function ProfilePage() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Map & Tiles — applies to every map in the app */}
|
||||
<Section title="🗺️ Map & Tiles">
|
||||
<p className="text-xs text-gray-500">
|
||||
Choose the map provider and style used everywhere in the app (activity maps, route tiles, mini-maps).
|
||||
Thunderforest and MapTiler need an API key — paste it below. Keys are stored only in this browser.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="Map provider">
|
||||
<Select value={mapProvider} onChange={e => setMapProvider(e.target.value)}>
|
||||
{Object.entries(MAP_PROVIDERS).map(([id, p]) => (
|
||||
<option key={id} value={id}>{p.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Map style">
|
||||
<Select value={mapStyle} onChange={e => setMapStyle(e.target.value)}>
|
||||
{Object.entries(providerDef.styles).map(([id, s]) => (
|
||||
<option key={id} value={id}>{s.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{providerDef.needsKey && (
|
||||
<Field
|
||||
label={`${providerDef.label} API key${providerDef.keyOptional ? ' (optional)' : ''}`}
|
||||
hint={providerDef.keyHint}
|
||||
>
|
||||
<Input value={mapKeys[mapProvider] || ''} placeholder="Paste your API key"
|
||||
onChange={e => setMapKey(mapProvider, e.target.value.trim())} />
|
||||
{providerDef.signupUrl && (
|
||||
<a href={providerDef.signupUrl} target="_blank" rel="noreferrer"
|
||||
className="text-xs text-blue-400 hover:text-blue-300 mt-1 inline-block">
|
||||
Get a free key →
|
||||
</a>
|
||||
)}
|
||||
{!providerDef.keyOptional && !mapKeys[mapProvider] && (
|
||||
<p className="text-yellow-400 text-xs mt-1">A key is required or tiles won’t load.</p>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Preview">
|
||||
<RouteTileMap polyline={SAMPLE_POLYLINE} sportType="running"
|
||||
className="h-40 w-full rounded-lg overflow-hidden border border-gray-800" />
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* Password change */}
|
||||
<Section title="Change Password">
|
||||
<div className="space-y-3">
|
||||
@@ -445,6 +577,94 @@ export default function ProfilePage() {
|
||||
})()}
|
||||
</Section>
|
||||
|
||||
{/* Strava Sync */}
|
||||
<Section title="🟠 Strava Sync">
|
||||
<p className="text-xs text-gray-500">
|
||||
Connect your Strava account to automatically import activities {formatSyncInterval(stravaConfig?.sync_interval_minutes)}.
|
||||
Works for anything that ends up on Strava — Apple Watch, the Strava phone app, or another GPS watch.
|
||||
</p>
|
||||
|
||||
{stReturn === 'connected' && (
|
||||
<p className="text-xs text-green-400">✓ Strava connected — your first sync has started.</p>
|
||||
)}
|
||||
{stReturn === 'error' && (
|
||||
<p className="text-xs text-red-400">Strava connection failed or was cancelled. Please try again.</p>
|
||||
)}
|
||||
{stReturn === 'scope' && (
|
||||
<p className="text-xs text-yellow-400">Please tick “View data about your activities” when authorizing, so private activities can sync.</p>
|
||||
)}
|
||||
|
||||
{!stravaConfig?.configured && (
|
||||
<p className="text-xs text-yellow-400">
|
||||
Strava API credentials aren’t set on the server. An admin must register an app at
|
||||
strava.com/settings/api and set <code className="text-gray-300">STRAVA_CLIENT_ID</code> /
|
||||
<code className="text-gray-300"> STRAVA_CLIENT_SECRET</code> (callback domain = this site’s domain).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{stravaConfig?.connected ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between bg-orange-900/20 border border-orange-800/40 rounded-lg px-3 py-2 text-xs flex-wrap gap-2">
|
||||
<span className="text-orange-300">✓ Connected{stravaConfig.athlete_name ? ` as ${stravaConfig.athlete_name}` : ''}</span>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{stravaConfig.last_sync_at && (
|
||||
<span className="text-gray-500">
|
||||
Last sync: {new Date(stravaConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
{stravaConfig.last_sync_status && (
|
||||
<span className={stravaConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : /error/i.test(stravaConfig.last_sync_status) ? 'text-red-400' : 'text-yellow-400'}>
|
||||
{stravaConfig.last_sync_status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer pt-1">
|
||||
<input type="checkbox" checked={stForm.sync_enabled}
|
||||
onChange={e => setStForm(f => ({ ...f, sync_enabled: e.target.checked }))}
|
||||
className="w-4 h-4 accent-orange-500" />
|
||||
<span className="text-sm text-gray-300">Enable automatic sync ({formatSyncInterval(stravaConfig?.sync_interval_minutes)})</span>
|
||||
</label>
|
||||
|
||||
<Field label="Sync lookback days" hint="How far back to pull on the first sync (-1 = all history). After that, scheduled syncs only refresh the last day or two. Large backfills may hit Strava's rate limits and resume on the next sync.">
|
||||
<Input type="number" value={stForm.sync_lookback_days} min={-1}
|
||||
onChange={e => setStForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap pt-1">
|
||||
<SaveButton
|
||||
onClick={() => saveStrava.mutate({
|
||||
sync_enabled: stForm.sync_enabled,
|
||||
sync_lookback_days: parseInt(stForm.sync_lookback_days, 10) || 30,
|
||||
})}
|
||||
loading={saveStrava.isPending}
|
||||
saved={stSaved}
|
||||
label="Update"
|
||||
/>
|
||||
<button
|
||||
onClick={() => triggerStrava.mutate()}
|
||||
disabled={stravaSyncing || triggerStrava.isPending}
|
||||
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
{stravaSyncing ? 'Syncing…' : '↻ Sync now'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Disconnect Strava?')) deleteStrava.mutate() }}
|
||||
className="text-red-400 hover:text-red-300 text-sm transition-colors">
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => connectStrava.mutate()}
|
||||
disabled={!stravaConfig?.configured || connectStrava.isPending}
|
||||
className="inline-flex items-center gap-2 bg-[#FC4C02] hover:bg-[#e34402] disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
{connectStrava.isPending ? 'Redirecting…' : 'Connect with Strava'}
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* PocketID — admin only */}
|
||||
{user?.is_admin && (
|
||||
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
||||
import { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
|
||||
import { useUnit } from '../hooks/useUnits'
|
||||
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
||||
|
||||
const SPORTS = ['running', 'cycling']
|
||||
@@ -44,7 +45,7 @@ function DistancePRs() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SPORTS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
@@ -69,6 +70,7 @@ function DistancePRs() {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||
@@ -108,6 +110,7 @@ function DistancePRs() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
{selectedDistance && history ? (
|
||||
@@ -153,6 +156,7 @@ function DistancePRs() {
|
||||
|
||||
function RouteRecords() {
|
||||
const navigate = useNavigate()
|
||||
const unit = useUnit()
|
||||
const { data: records, isLoading } = useQuery({
|
||||
queryKey: ['route-records'],
|
||||
queryFn: () => api.get('/records/routes').then(r => r.data),
|
||||
@@ -169,6 +173,7 @@ function RouteRecords() {
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||
@@ -176,8 +181,8 @@ function RouteRecords() {
|
||||
<th className="text-left px-3 py-3 font-medium">Route</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Distance</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Best time</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Pace</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Date</th>
|
||||
<th className="hidden sm:table-cell text-right px-3 py-3 font-medium">Pace</th>
|
||||
<th className="hidden sm:table-cell text-right px-3 py-3 font-medium">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -195,15 +200,15 @@ function RouteRecords() {
|
||||
{rec.route_name}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||
{formatDistance(rec.distance_m)}
|
||||
{formatDistance(rec.distance_m, unit)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||
{formatDuration(rec.duration_s)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||
{formatPace(rec.avg_speed_ms, rec.sport_type)}
|
||||
<td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
|
||||
{formatPace(rec.avg_speed_ms, rec.sport_type, unit)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||
<td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
|
||||
{formatDate(rec.start_time)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -211,6 +216,7 @@ function RouteRecords() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -239,6 +245,7 @@ function SegmentLeaderboard({ segmentId }) {
|
||||
|
||||
function SegmentRecords() {
|
||||
const [open, setOpen] = useState(null)
|
||||
const unit = useUnit()
|
||||
const { data: segments, isLoading } = useQuery({
|
||||
queryKey: ['segments'],
|
||||
queryFn: () => api.get('/segments/').then(r => r.data),
|
||||
@@ -255,6 +262,7 @@ function SegmentRecords() {
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||
@@ -282,7 +290,7 @@ function SegmentRecords() {
|
||||
{seg.name}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||
{formatDistance(seg.distance_m)}
|
||||
{formatDistance(seg.distance_m, unit)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
||||
@@ -303,6 +311,7 @@ function SegmentRecords() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -310,7 +319,7 @@ export default function RecordsPage() {
|
||||
const [tab, setTab] = useState('Distance PRs')
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Records</h1>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
|
||||
@@ -1,66 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import ActivityMap from '../components/activity/ActivityMap'
|
||||
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
||||
|
||||
// Decode Google encoded polyline to [[lat,lng], ...]
|
||||
function decodePolyline(encoded) {
|
||||
if (!encoded) return []
|
||||
const points = []
|
||||
let idx = 0, lat = 0, lng = 0
|
||||
while (idx < encoded.length) {
|
||||
let shift = 0, result = 0, byte
|
||||
do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20)
|
||||
lat += result & 1 ? ~(result >> 1) : result >> 1
|
||||
shift = 0; result = 0
|
||||
do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20)
|
||||
lng += result & 1 ? ~(result >> 1) : result >> 1
|
||||
points.push([lat / 1e5, lng / 1e5])
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
function RouteMap({ polyline, className = '', sportType = '' }) {
|
||||
const pts = decodePolyline(polyline)
|
||||
if (pts.length < 2) return (
|
||||
<div className={`bg-gray-800 rounded flex items-center justify-center text-gray-600 text-xs ${className}`}>
|
||||
no track
|
||||
</div>
|
||||
)
|
||||
const t = (sportType || '').toLowerCase()
|
||||
const stroke = (t.includes('cycl') || t.includes('bike') || t.includes('ride')) ? '#f97316' : '#3b82f6'
|
||||
const lats = pts.map(p => p[0]), lngs = pts.map(p => p[1])
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||
const rangeL = maxLng - minLng || 1e-5
|
||||
const rangeA = maxLat - minLat || 1e-5
|
||||
const pad = 4
|
||||
const w = 100, h = 60
|
||||
const toX = lng => pad + ((lng - minLng) / rangeL) * (w - pad * 2)
|
||||
const toY = lat => pad + ((maxLat - lat) / rangeA) * (h - pad * 2)
|
||||
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p[1]).toFixed(1)},${toY(p[0]).toFixed(1)}`).join(' ')
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className={`bg-gray-800 rounded ${className}`} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d={d} fill="none" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function routeSportStyle(sportType) {
|
||||
const t = (sportType || '').toLowerCase()
|
||||
if (t.includes('cycl') || t.includes('bike') || t.includes('ride'))
|
||||
return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400' }
|
||||
if (t.includes('run') || t.includes('jog') || t.includes('walk'))
|
||||
return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400' }
|
||||
return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400' }
|
||||
}
|
||||
import RouteTileMap from '../components/ui/RouteTileMap'
|
||||
import { formatDistance, formatDuration, formatDate, formatPace, sportColor } from '../utils/format'
|
||||
import { useUnit } from '../hooks/useUnits'
|
||||
|
||||
const MEDALS = ['🥇', '🥈', '🥉']
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'recent', label: 'Date last completed' },
|
||||
{ value: 'distance', label: 'Distance' },
|
||||
{ value: 'completions', label: 'Times completed' },
|
||||
]
|
||||
|
||||
function sortRoutes(routes, sortBy) {
|
||||
const arr = [...routes]
|
||||
if (sortBy === 'distance') {
|
||||
arr.sort((a, b) => (b.distance_m || 0) - (a.distance_m || 0))
|
||||
} else if (sortBy === 'completions') {
|
||||
arr.sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
|
||||
} else { // recent — most recently completed first, then newest route
|
||||
arr.sort((a, b) => {
|
||||
const at = a.last_activity_at ? new Date(a.last_activity_at) : new Date(a.created_at)
|
||||
const bt = b.last_activity_at ? new Date(b.last_activity_at) : new Date(b.created_at)
|
||||
return bt - at
|
||||
})
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
function RouteDetail({ selected, setSelected }) {
|
||||
const qc = useQueryClient()
|
||||
const unit = useUnit()
|
||||
const navigate = useNavigate()
|
||||
const [merging, setMerging] = useState(false)
|
||||
const [mergeTarget, setMergeTarget] = useState('')
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
@@ -101,6 +75,7 @@ function RouteDetail({ selected, setSelected }) {
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
setSelected(null)
|
||||
navigate('/routes')
|
||||
},
|
||||
})
|
||||
|
||||
@@ -110,12 +85,12 @@ function RouteDetail({ selected, setSelected }) {
|
||||
|
||||
return (
|
||||
<div className="col-span-full bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex gap-4 items-start min-w-0">
|
||||
<div className="w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start min-w-0 w-full sm:w-auto">
|
||||
<div className="w-full sm:w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
|
||||
{selected.reference_polyline
|
||||
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
|
||||
: <RouteMap polyline={selected.reference_polyline} className="w-full h-full" sportType={selected.sport_type} />}
|
||||
: <div className="w-full h-full bg-gray-800 flex items-center justify-center text-gray-600 text-xs">no track</div>}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{editingName ? (
|
||||
@@ -141,7 +116,7 @@ function RouteDetail({ selected, setSelected }) {
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
||||
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
|
||||
<span>{formatDistance(selected.distance_m)}</span>
|
||||
<span>{formatDistance(selected.distance_m, unit)}</span>
|
||||
{selected.auto_detected && (
|
||||
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
|
||||
)}
|
||||
@@ -171,7 +146,7 @@ function RouteDetail({ selected, setSelected }) {
|
||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
|
||||
<option value="">Select route to merge in…</option>
|
||||
{otherRoutes.map(r => (
|
||||
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
|
||||
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m, unit)})</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
@@ -218,7 +193,7 @@ function RouteDetail({ selected, setSelected }) {
|
||||
<span className={`font-mono text-xs w-16 text-right ${i === 0 ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||
{i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''}
|
||||
</span>
|
||||
<span className="text-gray-500 w-20 text-right">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
|
||||
<span className="text-gray-500 w-20 text-right">{formatPace(act.avg_speed_ms, selected.sport_type, unit)}</span>
|
||||
{act.avg_heart_rate
|
||||
? <span className="text-red-400 text-xs w-16 text-right">{Math.round(act.avg_heart_rate)} bpm</span>
|
||||
: <span className="w-16" />}
|
||||
@@ -232,9 +207,13 @@ function RouteDetail({ selected, setSelected }) {
|
||||
}
|
||||
|
||||
export default function RoutesPage() {
|
||||
const unit = useUnit()
|
||||
const { routeId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
||||
const [sortBy, setSortBy] = useState('recent')
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: routes } = useQuery({
|
||||
@@ -242,8 +221,17 @@ export default function RoutesPage() {
|
||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||
})
|
||||
|
||||
// Sort by most completions first
|
||||
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
|
||||
// Deep-link: when arriving at /routes/:routeId, pre-select that route.
|
||||
useEffect(() => {
|
||||
if (routeId && routes) {
|
||||
const found = routes.find(r => r.id === Number(routeId))
|
||||
if (found) setSelected(found)
|
||||
}
|
||||
}, [routeId, routes])
|
||||
|
||||
// Split into custom-named and auto-detected, sorted within each group.
|
||||
const customRoutes = sortRoutes((routes || []).filter(r => !r.auto_detected), sortBy)
|
||||
const autoRoutes = sortRoutes((routes || []).filter(r => r.auto_detected), sortBy)
|
||||
|
||||
const { data: recentActivities } = useQuery({
|
||||
queryKey: ['recent-activities-for-route'],
|
||||
@@ -258,23 +246,64 @@ export default function RoutesPage() {
|
||||
setShowCreate(false)
|
||||
setNewRoute({ name: '', activity_id: '' })
|
||||
setSelected(route)
|
||||
navigate(`/routes/${route.id}`)
|
||||
},
|
||||
})
|
||||
|
||||
const selectRoute = (route, isSelected) => {
|
||||
if (isSelected) { setSelected(null); navigate('/routes') }
|
||||
else { setSelected(route); navigate(`/routes/${route.id}`) }
|
||||
}
|
||||
|
||||
const renderGrid = (list) => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
{list.map(route => {
|
||||
const color = sportColor(route.sport_type)
|
||||
const isSelected = selected?.id === route.id
|
||||
return [
|
||||
<button key={route.id}
|
||||
onClick={() => selectRoute(route, isSelected)}
|
||||
style={isSelected ? { borderColor: color, backgroundColor: color + '14' } : undefined}
|
||||
className={`text-left rounded-xl border p-2 transition-all ${
|
||||
isSelected ? '' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
||||
}`}>
|
||||
<RouteTileMap polyline={route.reference_polyline} sportType={route.sport_type}
|
||||
className="w-full h-[11.5rem] rounded-lg overflow-hidden" />
|
||||
<p className="text-xs font-medium text-white mt-2 truncate">{route.name}</p>
|
||||
<div className="flex items-center justify-between mt-0.5 gap-1">
|
||||
<span className="text-xs text-gray-500">{formatDistance(route.distance_m, unit)}</span>
|
||||
{route.activity_count > 0 && (
|
||||
<span className="text-xs font-medium" style={{ color }}>{route.activity_count}×</span>
|
||||
)}
|
||||
</div>
|
||||
</button>,
|
||||
isSelected && <RouteDetail key={`detail-${route.id}`} selected={selected} setSelected={setSelected} />,
|
||||
]
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Routes are auto-detected when you run the same path twice. You can also create them manually.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-500">Sort by</label>
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{SORT_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors whitespace-nowrap">
|
||||
+ New route
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create route panel */}
|
||||
{showCreate && (
|
||||
@@ -297,7 +326,7 @@ export default function RoutesPage() {
|
||||
<option value="">Select an activity…</option>
|
||||
{recentActivities?.map(a => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{sportIcon(a.sport_type)} {a.name} — {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
|
||||
{a.name} — {formatDistance(a.distance_m, unit)} on {formatDate(a.start_time)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -317,7 +346,8 @@ export default function RoutesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route tile grid — selected route's detail expands inline under its row */}
|
||||
{/* Route tiles, grouped (custom above auto-detected) — the selected
|
||||
route's detail expands inline under its row within each grid. */}
|
||||
{routes?.length === 0 && !showCreate ? (
|
||||
<div className="text-center py-12 text-gray-600">
|
||||
<p className="text-3xl mb-2">🗺️</p>
|
||||
@@ -325,29 +355,19 @@ export default function RoutesPage() {
|
||||
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
{sortedRoutes.map(route => {
|
||||
const style = routeSportStyle(route.sport_type)
|
||||
const isSelected = selected?.id === route.id
|
||||
return [
|
||||
<button key={route.id}
|
||||
onClick={() => setSelected(isSelected ? null : route)}
|
||||
className={`text-left rounded-xl border p-2 transition-all ${
|
||||
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
|
||||
}`}>
|
||||
<RouteMap polyline={route.reference_polyline} className="w-full h-20" sportType={route.sport_type} />
|
||||
<p className="text-xs font-medium text-white mt-2 truncate">{route.name}</p>
|
||||
<div className="flex items-center justify-between mt-0.5 gap-1">
|
||||
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
|
||||
{route.activity_count > 0 && (
|
||||
<span className={`text-xs font-medium ${style.accent}`}>{route.activity_count}×</span>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
{customRoutes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-gray-300">Custom routes</h2>
|
||||
{renderGrid(customRoutes)}
|
||||
</div>
|
||||
{route.auto_detected && <span className="text-xs text-gray-600">auto</span>}
|
||||
</button>,
|
||||
isSelected && <RouteDetail key={`detail-${route.id}`} selected={selected} setSelected={setSelected} />,
|
||||
]
|
||||
})}
|
||||
)}
|
||||
{autoRoutes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-gray-300">Auto-detected routes</h2>
|
||||
{renderGrid(autoRoutes)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid,
|
||||
} from 'recharts'
|
||||
import api from '../utils/api'
|
||||
import { formatDuration, sportColor, convertKm, distanceUnitLabel, formatElevation } from '../utils/format'
|
||||
import { useUnit } from '../hooks/useUnits'
|
||||
import SportIcon from '../components/ui/SportIcon'
|
||||
|
||||
function StatTile({ label, value, sub }) {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{value}</p>
|
||||
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SummaryPage() {
|
||||
const unit = useUnit()
|
||||
const distLabel = distanceUnitLabel(unit)
|
||||
const dist = km => `${convertKm(km, unit).toLocaleString(undefined, { maximumFractionDigits: 0 })} ${distLabel}`
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['stats-summary'],
|
||||
queryFn: () => api.get('/activities/stats/summary').then(r => r.data),
|
||||
})
|
||||
|
||||
const byYear = data?.by_year || []
|
||||
const allTime = data?.all_time
|
||||
const chartData = [...byYear].reverse().map(y => ({
|
||||
year: String(y.year),
|
||||
distance: Math.round(convertKm(y.distance_km, unit)),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Summary</h1>
|
||||
<Link to="/activities" className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
← Activities
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-gray-500 text-sm">Loading…</div>
|
||||
) : !allTime || allTime.count === 0 ? (
|
||||
<div className="text-center py-16 text-gray-600">
|
||||
<p className="text-lg">No activities yet</p>
|
||||
<p className="text-sm mt-1">
|
||||
<Link to="/upload" className="text-blue-400 hover:underline">Import your data</Link> to see your totals
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* All-time totals */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-400 mb-2">All time</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatTile label="Activities" value={allTime.count.toLocaleString()} />
|
||||
<StatTile label="Distance" value={dist(allTime.distance_km)} />
|
||||
<StatTile label="Moving time" value={formatDuration(allTime.duration_s)} />
|
||||
<StatTile label="Elevation gain" value={formatElevation(allTime.elevation_m, unit)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Distance per year */}
|
||||
{chartData.length > 1 && (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-400 mb-3">Distance per year ({distLabel})</h2>
|
||||
<div style={{ width: '100%', height: 220 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis dataKey="year" tick={{ fontSize: 11, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: '#6b7280' }} axisLine={false} tickLine={false} width={44} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||
labelStyle={{ color: '#e5e7eb' }}
|
||||
formatter={v => [`${v.toLocaleString()} ${distLabel}`, 'Distance']}
|
||||
/>
|
||||
<Bar dataKey="distance" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-year breakdown */}
|
||||
<div className="space-y-4">
|
||||
{byYear.map(y => (
|
||||
<div key={y.year} className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div className="flex items-baseline justify-between mb-3 flex-wrap gap-x-4 gap-y-1">
|
||||
<h3 className="text-lg font-bold text-white">{y.year}</h3>
|
||||
<div className="text-sm text-gray-400 flex flex-wrap gap-x-4">
|
||||
<span><span className="text-gray-200 font-medium">{y.count}</span> activities</span>
|
||||
<span><span className="text-gray-200 font-medium">{dist(y.distance_km)}</span></span>
|
||||
<span><span className="text-gray-200 font-medium">{formatDuration(y.duration_s)}</span></span>
|
||||
<span>↑ <span className="text-gray-200 font-medium">{formatElevation(y.elevation_m, unit)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{y.by_sport.map(s => (
|
||||
<div key={s.sport_type} className="flex items-center gap-3 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5 w-32 capitalize" style={{ color: sportColor(s.sport_type) }}>
|
||||
<SportIcon sport={s.sport_type} size={15} color="currentColor" />
|
||||
{(s.sport_type || 'other').replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="text-gray-500 w-20">{s.count} act.</span>
|
||||
<span className="text-gray-300 w-24">{dist(s.distance_km)}</span>
|
||||
<span className="text-gray-500">{formatDuration(s.duration_s)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -140,9 +140,169 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Strava bulk export with a dry-run preview: upload → analyse (new vs. already
|
||||
// present, Garmin preferred) → confirm import of just the new ones. Avoids
|
||||
// re-uploading by importing the files the preview already extracted (via token).
|
||||
function StravaExportZone() {
|
||||
const qc = useQueryClient()
|
||||
const [phase, setPhase] = useState('idle') // idle|uploading|analyzing|report|importing|done|error
|
||||
const [report, setReport] = useState(null)
|
||||
const [token, setToken] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const reset = () => { setReport(null); setToken(null); setError('') }
|
||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||
|
||||
const pollAnalyze = useCallback((taskId) => {
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/upload/task/${taskId}`)
|
||||
if (data.status === 'SUCCESS') {
|
||||
clearInterval(pollRef.current)
|
||||
setReport(data.result)
|
||||
setPhase('report')
|
||||
} else if (data.status === 'FAILURE') {
|
||||
clearInterval(pollRef.current)
|
||||
setError('Analysis failed — please try again')
|
||||
setPhase('error')
|
||||
}
|
||||
} catch { /* ignore transient poll errors */ }
|
||||
}, 2000)
|
||||
}, [])
|
||||
|
||||
const onDrop = useCallback(async (accepted) => {
|
||||
const file = accepted[0]
|
||||
if (!file) return
|
||||
reset()
|
||||
setPhase('uploading')
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const { data } = await api.post('/upload/strava-export?dry_run=true', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
setToken(data.token)
|
||||
setPhase('analyzing')
|
||||
pollAnalyze(data.task_id)
|
||||
} catch (e) {
|
||||
setError(e.response?.data?.detail || 'Upload failed')
|
||||
setPhase('error')
|
||||
}
|
||||
}, [pollAnalyze])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop, accept: { 'application/zip': ['.zip'] }, multiple: false,
|
||||
})
|
||||
|
||||
const doImport = async () => {
|
||||
setPhase('importing')
|
||||
try {
|
||||
await api.post('/upload/strava-export/confirm', { token })
|
||||
setPhase('done')
|
||||
qc.invalidateQueries({ queryKey: ['activities'] })
|
||||
} catch (e) {
|
||||
setError(e.response?.data?.detail || 'Import failed')
|
||||
setPhase('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-2xl">🚴</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Strava bulk export</h3>
|
||||
<p className="text-xs text-gray-500">Preview duplicates before importing — Garmin data is kept on a match</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(phase === 'idle' || phase === 'uploading') && (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragActive ? 'border-blue-500 bg-blue-950/30' : 'border-gray-700 hover:border-gray-500 hover:bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{phase === 'uploading'
|
||||
? <p className="text-blue-400 text-sm animate-pulse">Uploading…</p>
|
||||
: <div>
|
||||
<p className="text-gray-400 text-sm">Drag & drop your Strava archive .zip, or click to browse</p>
|
||||
<p className="text-gray-600 text-xs mt-1">We’ll scan it and show what’s new before importing</p>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'analyzing' && (
|
||||
<p className="text-sm text-blue-400 animate-pulse py-4 text-center">Scanning archive for duplicates…</p>
|
||||
)}
|
||||
|
||||
{phase === 'report' && report && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="bg-green-950/30 border border-green-900/40 rounded-lg py-2">
|
||||
<div className="text-xl font-bold text-green-400">{report.new}</div>
|
||||
<div className="text-xs text-gray-500">new</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg py-2">
|
||||
<div className="text-xl font-bold text-gray-300">{report.duplicate}</div>
|
||||
<div className="text-xs text-gray-500">already have</div>
|
||||
</div>
|
||||
<div className="bg-amber-950/20 border border-amber-900/40 rounded-lg py-2">
|
||||
<div className="text-xl font-bold text-amber-400">{report.unreadable}</div>
|
||||
<div className="text-xs text-gray-500">unreadable</div>
|
||||
</div>
|
||||
</div>
|
||||
{report.new_samples?.length > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
New e.g.: <span className="text-gray-400">{report.new_samples.slice(0, 4).join(' · ')}</span>
|
||||
{report.new > 4 ? ' …' : ''}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={doImport}
|
||||
disabled={report.new === 0}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
{report.new === 0 ? 'Nothing new to import' : `Import ${report.new} new ${report.new === 1 ? 'activity' : 'activities'}`}
|
||||
</button>
|
||||
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||
Choose another file
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">Duplicates are skipped automatically — your existing Garmin activities are kept.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'importing' && (
|
||||
<p className="text-sm text-blue-400 animate-pulse py-4 text-center">Queuing import…</p>
|
||||
)}
|
||||
|
||||
{phase === 'done' && (
|
||||
<div className="py-3 space-y-2">
|
||||
<p className="text-sm text-green-400">✓ Import queued — new activities will appear shortly as they process.</p>
|
||||
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||
Import another export
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'error' && (
|
||||
<div className="py-3 space-y-2">
|
||||
<p className="text-sm text-red-400">{error || 'Something went wrong'}</p>
|
||||
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Import Data</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
@@ -169,20 +329,21 @@ export default function UploadPage() {
|
||||
<li>Click "Request Your Archive"</li>
|
||||
<li>Download and upload the ZIP file below</li>
|
||||
</ol>
|
||||
<p className="text-gray-500 text-xs mt-2">Handles the gzipped <code>.fit.gz</code>/<code>.gpx.gz</code>/<code>.tcx.gz</code> files Strava puts in the archive. For ongoing sync, connect Strava on the Profile page instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* Single FIT/GPX */}
|
||||
{/* Single FIT/GPX/TCX */}
|
||||
<UploadZone
|
||||
title="Single activity"
|
||||
description="Upload a .fit or .gpx file"
|
||||
description="Upload a .fit, .gpx or .tcx file"
|
||||
icon="🏃"
|
||||
endpoint="/upload/activity"
|
||||
accept={{
|
||||
'application/octet-stream': ['.fit'],
|
||||
'application/gpx+xml': ['.gpx'],
|
||||
'text/xml': ['.gpx'],
|
||||
'text/xml': ['.gpx', '.tcx'],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -195,14 +356,8 @@ export default function UploadPage() {
|
||||
accept={{ 'application/zip': ['.zip'] }}
|
||||
/>
|
||||
|
||||
{/* Strava export */}
|
||||
<UploadZone
|
||||
title="Strava bulk export"
|
||||
description="Upload your Strava archive ZIP"
|
||||
icon="🚴"
|
||||
endpoint="/upload/strava-export"
|
||||
accept={{ 'application/zip': ['.zip'] }}
|
||||
/>
|
||||
{/* Strava export — preview duplicates, then import */}
|
||||
<StravaExportZone />
|
||||
|
||||
{/* Ongoing FIT files */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl space-y-6">
|
||||
<div className="p-4 md:p-6 max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Users</h1>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
@@ -43,6 +43,7 @@ export default function UsersPage() {
|
||||
{isLoading ? (
|
||||
<p className="p-5 text-sm text-gray-500">Loading…</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 border-b border-gray-800">
|
||||
@@ -91,6 +92,7 @@ export default function UsersPage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
// Unit conversion constants. Distances/elevations are stored in metres (and some
|
||||
// API stats in kilometres); imperial display is derived on the fly — nothing is
|
||||
// stored in miles. The active unit ('km' | 'mi') comes from the useUnits store.
|
||||
const M_PER_MI = 1609.344
|
||||
const KM_PER_MI = 1.609344
|
||||
const FT_PER_M = 3.280839895
|
||||
const MS_TO_MPH = 2.2369363
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
if (!seconds) return '--'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
@@ -7,28 +15,48 @@ export function formatDuration(seconds) {
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatPace(speedMs, sportType = 'running') {
|
||||
export function formatPace(speedMs, sportType = 'running', unit = 'km') {
|
||||
if (!speedMs || speedMs <= 0) return '--'
|
||||
if (sportType === 'cycling') {
|
||||
if (unit === 'mi') return `${(speedMs * MS_TO_MPH).toFixed(1)} mph`
|
||||
return `${(speedMs * 3.6).toFixed(1)} km/h`
|
||||
}
|
||||
const secsPerKm = 1000 / speedMs
|
||||
const mins = Math.floor(secsPerKm / 60)
|
||||
const secs = Math.floor(secsPerKm % 60)
|
||||
return `${mins}:${String(secs).padStart(2, '0')} /km`
|
||||
const distPerUnit = unit === 'mi' ? M_PER_MI : 1000
|
||||
const secsPerUnit = distPerUnit / speedMs
|
||||
const mins = Math.floor(secsPerUnit / 60)
|
||||
const secs = Math.floor(secsPerUnit % 60)
|
||||
return `${mins}:${String(secs).padStart(2, '0')} ${unit === 'mi' ? '/mi' : '/km'}`
|
||||
}
|
||||
|
||||
export function formatDistance(metres) {
|
||||
export function formatDistance(metres, unit = 'km') {
|
||||
if (!metres) return '--'
|
||||
if (unit === 'mi') {
|
||||
const mi = metres / M_PER_MI
|
||||
if (mi < 0.1) return `${Math.round(metres * FT_PER_M)} ft`
|
||||
return `${mi.toFixed(2)} mi`
|
||||
}
|
||||
if (metres >= 1000) return `${(metres / 1000).toFixed(2)} km`
|
||||
return `${Math.round(metres)} m`
|
||||
}
|
||||
|
||||
export function formatElevation(metres) {
|
||||
export function formatElevation(metres, unit = 'km') {
|
||||
if (metres == null) return '--'
|
||||
if (unit === 'mi') return `${Math.round(metres * FT_PER_M)} ft`
|
||||
return `${Math.round(metres)} m`
|
||||
}
|
||||
|
||||
// Convert a value already expressed in kilometres (e.g. API YTD/weekly stats) to
|
||||
// the active unit. Returns a number so callers can format/chart it themselves.
|
||||
export function convertKm(km, unit = 'km') {
|
||||
if (km == null) return null
|
||||
return unit === 'mi' ? km / KM_PER_MI : km
|
||||
}
|
||||
|
||||
// Short label for the active distance unit ('km' | 'mi'), for axis/column titles.
|
||||
export function distanceUnitLabel(unit = 'km') {
|
||||
return unit === 'mi' ? 'mi' : 'km'
|
||||
}
|
||||
|
||||
export function formatHeartRate(bpm) {
|
||||
if (!bpm) return '--'
|
||||
return `${Math.round(bpm)} bpm`
|
||||
@@ -77,18 +105,35 @@ export function hrZoneColor(zone) {
|
||||
return colors[zone] || '#9ca3af'
|
||||
}
|
||||
|
||||
export function sportIcon(sportType) {
|
||||
const icons = {
|
||||
running: '🏃', cycling: '🚴', hiking: '🥾',
|
||||
walking: '🚶', other: '⚡',
|
||||
}
|
||||
return icons[sportType?.toLowerCase()] || '⚡'
|
||||
// Standard metric colours, used everywhere a metric is charted or shown as a stat
|
||||
// so the whole platform stays consistent. (Sleep keeps its per-stage graph colours;
|
||||
// SLEEP here is the light-sleep violet used for sleep *stat* text. VO2 max is the
|
||||
// only metric coloured dynamically — by its gauge category — so it lives elsewhere.)
|
||||
export const METRIC_COLOR = {
|
||||
HEART_RATE: '#ef4444', // red
|
||||
SLEEP: '#a78bfa', // light-sleep violet (stat text)
|
||||
STRESS: '#f97316', // orange
|
||||
STEPS: '#fbbf24', // yellow
|
||||
WEIGHT: '#3b82f6', // blue
|
||||
}
|
||||
|
||||
// Gym / no-GPS workout types (HIIT, strength, cardio machines etc.) — coloured
|
||||
// red so they stand out from the GPS-based outdoor activities.
|
||||
const GYM_SPORTS = new Set([
|
||||
'hiit', 'training', 'fitness_equipment',
|
||||
'strength_training', 'strength', 'cardio_training', 'cardio',
|
||||
])
|
||||
|
||||
export function sportColor(sportType) {
|
||||
const s = sportType?.toLowerCase()
|
||||
const colors = {
|
||||
running: '#3b82f6', cycling: '#f97316',
|
||||
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
|
||||
running: '#22c55e', // green
|
||||
cycling: '#f97316', // orange
|
||||
hiking: '#84cc16', // lime
|
||||
walking: '#2dd4bf', // teal
|
||||
swimming:'#38bdf8', // sky
|
||||
other: '#9ca3af', // gray
|
||||
}
|
||||
return colors[sportType?.toLowerCase()] || '#6b7280'
|
||||
if (GYM_SPORTS.has(s)) return '#ef4444' // red
|
||||
return colors[s] || '#9ca3af'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Central catalogue of map tile providers + styles used across the whole app.
|
||||
// The active provider/style/API-key is chosen globally on the Profile page and
|
||||
// stored in the useMapSettings store; every Leaflet map resolves its base layer
|
||||
// through resolveTile() so a single setting drives the entire application.
|
||||
|
||||
const OSM_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OSM contributors</a>'
|
||||
|
||||
// Each provider lists its selectable styles (keyed by the id used in the tile
|
||||
// URL) and a build(styleId, key) that returns a Leaflet URL template. needsKey
|
||||
// providers read the user's key from the store; others ignore it.
|
||||
export const MAP_PROVIDERS = {
|
||||
thunderforest: {
|
||||
label: 'Thunderforest',
|
||||
needsKey: true,
|
||||
keyOptional: true, // a built-in default key exists
|
||||
keyHint: 'Free key at thunderforest.com. A shared default key is built in but may be rate-limited — add your own for reliability.',
|
||||
signupUrl: 'https://www.thunderforest.com/',
|
||||
attribution: `© <a href="https://www.thunderforest.com/">Thunderforest</a>, ${OSM_ATTR}`,
|
||||
maxZoom: 22,
|
||||
styles: {
|
||||
outdoors: { label: 'Outdoors' },
|
||||
cycle: { label: 'OpenCycleMap' },
|
||||
landscape: { label: 'Landscape' },
|
||||
atlas: { label: 'Atlas' },
|
||||
transport: { label: 'Transport' },
|
||||
'transport-dark': { label: 'Transport Dark' },
|
||||
pioneer: { label: 'Pioneer' },
|
||||
neighbourhood: { label: 'Neighbourhood' },
|
||||
'spinal-map': { label: 'Spinal' },
|
||||
},
|
||||
build: (styleId, key) =>
|
||||
`https://{s}.tile.thunderforest.com/${styleId}/{z}/{x}/{y}.png?apikey=${key || ''}`,
|
||||
},
|
||||
|
||||
maptiler: {
|
||||
label: 'MapTiler',
|
||||
needsKey: true,
|
||||
keyHint: 'Free key required from maptiler.com/cloud — there is no built-in key.',
|
||||
signupUrl: 'https://www.maptiler.com/cloud/',
|
||||
attribution: `© <a href="https://www.maptiler.com/">MapTiler</a>, ${OSM_ATTR}`,
|
||||
maxZoom: 22,
|
||||
styles: {
|
||||
'streets-v2': { label: 'Streets' },
|
||||
'outdoor-v2': { label: 'Outdoor' },
|
||||
'topo-v2': { label: 'Topo' },
|
||||
'winter-v2': { label: 'Winter' },
|
||||
satellite: { label: 'Satellite' },
|
||||
hybrid: { label: 'Satellite + labels' },
|
||||
'basic-v2': { label: 'Basic' },
|
||||
dataviz: { label: 'Dataviz Light' },
|
||||
'dataviz-dark': { label: 'Dataviz Dark' },
|
||||
},
|
||||
build: (styleId, key) =>
|
||||
`https://api.maptiler.com/maps/${styleId}/{z}/{x}/{y}.png?key=${key || ''}`,
|
||||
},
|
||||
|
||||
carto: {
|
||||
label: 'CARTO (no key)',
|
||||
needsKey: false,
|
||||
attribution: `${OSM_ATTR} © <a href="https://carto.com/">CARTO</a>`,
|
||||
maxZoom: 20,
|
||||
styles: {
|
||||
dark_all: { label: 'Dark Matter' },
|
||||
'rastertiles/voyager': { label: 'Voyager' },
|
||||
light_all: { label: 'Positron (light)' },
|
||||
},
|
||||
build: (styleId) => `https://{s}.basemaps.cartocdn.com/${styleId}/{z}/{x}/{y}{r}.png`,
|
||||
},
|
||||
|
||||
osm: {
|
||||
label: 'OpenStreetMap (no key)',
|
||||
needsKey: false,
|
||||
attribution: OSM_ATTR,
|
||||
maxZoom: 19,
|
||||
styles: { standard: { label: 'Standard' } },
|
||||
build: () => 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
},
|
||||
|
||||
esri: {
|
||||
label: 'Esri (no key)',
|
||||
needsKey: false,
|
||||
attribution: '© <a href="https://www.esri.com/">Esri</a>',
|
||||
maxZoom: 19,
|
||||
styles: {
|
||||
World_Imagery: { label: 'Satellite' },
|
||||
World_Topo_Map: { label: 'Topographic' },
|
||||
World_Street_Map: { label: 'Street' },
|
||||
},
|
||||
build: (styleId) =>
|
||||
`https://server.arcgisonline.com/ArcGIS/rest/services/${styleId}/MapServer/tile/{z}/{y}/{x}`,
|
||||
},
|
||||
}
|
||||
|
||||
export const DEFAULT_MAP_SETTINGS = { provider: 'thunderforest', style: 'outdoors', keys: {} }
|
||||
|
||||
function tileFor(provider, style, keys = {}) {
|
||||
const p = MAP_PROVIDERS[provider] || MAP_PROVIDERS[DEFAULT_MAP_SETTINGS.provider]
|
||||
const styleIds = Object.keys(p.styles)
|
||||
const styleId = p.styles[style] ? style : styleIds[0]
|
||||
const key = p.needsKey ? (keys[provider] || '') : ''
|
||||
return {
|
||||
url: p.build(styleId, key),
|
||||
attribution: p.attribution,
|
||||
maxZoom: p.maxZoom || 19,
|
||||
subdomains: 'abc', // ignored by Leaflet when the URL has no {s}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the active base tile for a map. With { satellite: true } it returns an
|
||||
// imagery layer instead — MapTiler satellite when the user is on MapTiler with a
|
||||
// key, otherwise free Esri World Imagery — so a "Satellite" toggle works for any
|
||||
// configured provider.
|
||||
export function resolveTile(settings, { satellite = false } = {}) {
|
||||
if (satellite) {
|
||||
if (settings?.provider === 'maptiler' && settings?.keys?.maptiler) {
|
||||
return tileFor('maptiler', 'satellite', settings.keys)
|
||||
}
|
||||
return tileFor('esri', 'World_Imagery', {})
|
||||
}
|
||||
return tileFor(settings?.provider, settings?.style, settings?.keys || {})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// VO2 max rating categories (Garmin / Cooper Institute thresholds), shared by the
|
||||
// Health page gauge/trend and the Dashboard stat + mini-widget so VO2 max is always
|
||||
// coloured by its current rating.
|
||||
|
||||
// [maxAge, [fair_min, good_min, excellent_min, superior_min]]
|
||||
// value < fair_min → Poor; >= superior_min → Superior
|
||||
const VO2_MALE = [
|
||||
[29, [41.7, 45.4, 51.1, 55.4]],
|
||||
[39, [40.5, 44.0, 48.3, 54.0]],
|
||||
[49, [38.5, 42.4, 46.4, 52.5]],
|
||||
[59, [35.6, 39.2, 43.4, 48.9]],
|
||||
[69, [32.3, 35.5, 39.5, 45.7]],
|
||||
[Infinity, [29.4, 32.3, 36.7, 42.1]],
|
||||
]
|
||||
const VO2_FEMALE = [
|
||||
[29, [36.1, 39.5, 43.9, 49.6]],
|
||||
[39, [34.4, 37.8, 42.4, 47.4]],
|
||||
[49, [33.0, 36.3, 39.7, 45.3]],
|
||||
[59, [30.1, 33.0, 36.7, 41.1]],
|
||||
[69, [27.5, 30.0, 33.0, 37.8]],
|
||||
[Infinity, [25.9, 28.1, 30.9, 36.7]],
|
||||
]
|
||||
|
||||
export const VO2_CATEGORIES = [
|
||||
{ label: 'Poor', color: '#ef4444' },
|
||||
{ label: 'Fair', color: '#f97316' },
|
||||
{ label: 'Good', color: '#22c55e' },
|
||||
{ label: 'Excellent', color: '#3b82f6' },
|
||||
{ label: 'Superior', color: '#a855f7' },
|
||||
]
|
||||
|
||||
// Age/sex category boundary table (6 boundary values around the 5 colour bands).
|
||||
export function vo2Thresholds(age, sex) {
|
||||
const table = sex === 'female' ? VO2_FEMALE : VO2_MALE
|
||||
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
||||
return row[1]
|
||||
}
|
||||
|
||||
export function getVo2Category(value, age, sex) {
|
||||
const thresholds = vo2Thresholds(age, sex)
|
||||
// thresholds are lower-bounds: count how many the value meets or exceeds
|
||||
const idx = thresholds.reduce((n, t) => (value >= t ? n + 1 : n), 0)
|
||||
return VO2_CATEGORIES[idx]
|
||||
}
|
||||
|
||||
const ageFromBirthYear = (birthYear) =>
|
||||
birthYear ? new Date().getFullYear() - birthYear : 40
|
||||
|
||||
// Convenience: the rating colour for a VO2 value given the user's profile.
|
||||
export function vo2Color(value, birthYear, sex, fallback = '#3b82f6') {
|
||||
if (value == null) return fallback
|
||||
return getVo2Category(value, ageFromBirthYear(birthYear), sex)?.color || fallback
|
||||
}
|
||||
@@ -6,7 +6,8 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
// 'backend' only resolves inside docker compose; override for host-side dev
|
||||
target: process.env.VITE_PROXY_TARGET || 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -216,6 +216,19 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def no_store_api_responses(request, call_next):
|
||||
"""Authenticated API data must never be cached by the browser/proxy. Without
|
||||
this, browsers (notably Edge) can heuristically cache GETs like
|
||||
/api/garmin-sync/config and keep showing stale state (e.g. "not connected")
|
||||
until a manual cache clear."""
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/api/"):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
|
||||
app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
|
||||
|
||||
@@ -127,18 +127,24 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
|
||||
continue
|
||||
|
||||
# Slow-path dedup: activity imported via bulk export (no garmin_activity_id).
|
||||
# Check by start_time; stamp the ID so future syncs skip it in the fast path.
|
||||
act_start_str = act.get("startTimeLocal") or act.get("startTimeGMT") or ""
|
||||
# Match on the actual start instant (not just the date — two activities on
|
||||
# the same day are distinct), comparing GMT-to-GMT since FIT start_times are
|
||||
# stored in UTC. A small window absorbs sub-second/rounding differences.
|
||||
act_start_str = act.get("startTimeGMT") or ""
|
||||
if act_start_str:
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
from datetime import datetime as _dt, timezone as _tz
|
||||
act_start = _dt.fromisoformat(act_start_str.replace("Z", "+00:00"))
|
||||
if act_start.tzinfo is None:
|
||||
act_start = act_start.replace(tzinfo=_tz.utc) # startTimeGMT is UTC
|
||||
window = timedelta(minutes=5)
|
||||
time_match = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
func.date(Activity.start_time) == act_start.date(),
|
||||
Activity.start_time >= act_start - window,
|
||||
Activity.start_time <= act_start + window,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
).scalars().first()
|
||||
if time_match:
|
||||
if not time_match.garmin_activity_id:
|
||||
time_match.garmin_activity_id = garmin_id
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
|
||||
@@ -7,6 +7,14 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Never let the browser serve a stale SPA shell: it references hashed asset
|
||||
# filenames, so a cached index.html strands users on an old build after a
|
||||
# deploy (Edge heuristically caches it when no Cache-Control is set). Force
|
||||
# revalidation — a cheap 304 when unchanged — so new deploys are picked up.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
@@ -13,18 +13,16 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^2.12.7",
|
||||
"date-fns": "^3.6.0",
|
||||
"clsx": "^2.1.1",
|
||||
"zustand": "^4.5.2",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"axios": "^1.7.2",
|
||||
"react-dropzone": "^14.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.2.13",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"vite": "^8.1.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4"
|
||||
|
||||
Reference in New Issue
Block a user