Compare commits
35
Commits
e7123ee5db
..
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 |
@@ -54,7 +54,7 @@ docker exec -it milevault_db psql -U milevault -d milevault
|
|||||||
`docker-compose.yml` — build from source (dev/CI).
|
`docker-compose.yml` — build from source (dev/CI).
|
||||||
`docker-compose.deploy.yml` — pull pre-built images from the Gitea registry (production).
|
`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`.
|
`./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`.
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ docker compose -f docker-compose.deploy.yml up -d
|
|||||||
| `redis` | Celery broker + result backend |
|
| `redis` | Celery broker + result backend |
|
||||||
| `backend` | FastAPI (async) — uvicorn, single worker |
|
| `backend` | FastAPI (async) — uvicorn, single worker |
|
||||||
| `worker` | Celery worker — synchronous SQLAlchemy (asyncio incompatible with prefork) |
|
| `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 |
|
| `frontend` | React SPA built by Vite at container build time |
|
||||||
| `nginx` | Reverse proxy, serves the SPA |
|
| `nginx` | Reverse proxy, serves the SPA |
|
||||||
|
|
||||||
@@ -90,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)
|
- `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)
|
- `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`
|
- `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` (the old `RouteSegment` model was removed in the segments rewrite; a new GPS-geometry `Segment`/`SegmentEffort` pair replaces it)
|
- `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 and GPX files; handles raw FIT timestamps (FIT epoch offset 631065600s) and semicircle→degree conversion
|
- `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/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/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
|
- `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`, `backfill_moving_time`, `backfill_indoor_distances`, `recompute_personal_records_all`
|
- `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
|
### Key design decisions
|
||||||
|
|
||||||
@@ -110,21 +111,52 @@ docker compose -f docker-compose.deploy.yml up -d
|
|||||||
|
|
||||||
**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.
|
**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/`)
|
### Frontend (`frontend/src/`)
|
||||||
|
|
||||||
- `App.jsx` — React Router v6, `RequireAuth` wrapper, all routes defined here
|
- `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/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/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
|
- `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.
|
- `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc.
|
||||||
- `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/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`, `ActivityDetail`, `Routes`, `Records`, `Health`, `Upload`, `Profile`, `Users`, `Login`
|
- `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/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.
|
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
|
## Environment variables
|
||||||
|
|
||||||
Required in `.env` (or passed to Docker Compose):
|
Required in `.env` (or passed to Docker Compose):
|
||||||
@@ -143,9 +175,11 @@ Required in `.env` (or passed to Docker Compose):
|
|||||||
| `BASE_URL` | Used for PocketID OAuth callback redirect URI |
|
| `BASE_URL` | Used for PocketID OAuth callback redirect URI |
|
||||||
| `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) |
|
| `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) |
|
||||||
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer (baked at build time) |
|
| `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_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
|
||||||
| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |
|
| `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/
|
## milevault_export/
|
||||||
|
|
||||||
|
|||||||
+220
-29
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, desc, delete
|
from sqlalchemy import select, func, desc, delete
|
||||||
|
from sqlalchemy.orm import selectinload, aliased
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -15,6 +16,7 @@ router = APIRouter()
|
|||||||
class ActivitySummary(BaseModel):
|
class ActivitySummary(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
original_name: Optional[str] = None
|
||||||
sport_type: str
|
sport_type: str
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
distance_m: Optional[float]
|
distance_m: Optional[float]
|
||||||
@@ -28,6 +30,8 @@ class ActivitySummary(BaseModel):
|
|||||||
bounding_box: Optional[dict]
|
bounding_box: Optional[dict]
|
||||||
hr_zones: Optional[dict]
|
hr_zones: Optional[dict]
|
||||||
named_route_id: Optional[int]
|
named_route_id: Optional[int]
|
||||||
|
named_route_name: Optional[str] = None
|
||||||
|
active_spans: Optional[list] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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])
|
@router.get("/", response_model=List[ActivitySummary])
|
||||||
async def list_activities(
|
async def list_activities(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -107,18 +215,17 @@ async def list_activities(
|
|||||||
sport_type: Optional[str] = None,
|
sport_type: Optional[str] = None,
|
||||||
from_date: Optional[datetime] = None,
|
from_date: Optional[datetime] = None,
|
||||||
to_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),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
q = select(Activity).where(Activity.user_id == current_user.id)
|
q = select(Activity).options(selectinload(Activity.named_route)).where(Activity.user_id == current_user.id)
|
||||||
|
q = _apply_activity_filters(
|
||||||
if sport_type:
|
q, sport_type=sport_type, from_date=from_date, to_date=to_date, year=year,
|
||||||
q = q.where(Activity.sport_type == sport_type)
|
min_distance_km=min_distance_km, max_distance_km=max_distance_km,
|
||||||
if from_date:
|
)
|
||||||
q = q.where(Activity.start_time >= from_date)
|
|
||||||
if to_date:
|
|
||||||
q = q.where(Activity.start_time <= to_date)
|
|
||||||
|
|
||||||
q = q.order_by(desc(Activity.start_time))
|
q = q.order_by(desc(Activity.start_time))
|
||||||
q = q.offset((page - 1) * per_page).limit(per_page)
|
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),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Activity).where(
|
select(Activity).options(selectinload(Activity.named_route)).where(
|
||||||
Activity.id == activity_id,
|
Activity.id == activity_id,
|
||||||
Activity.user_id == current_user.id,
|
Activity.user_id == current_user.id,
|
||||||
)
|
)
|
||||||
@@ -160,17 +267,27 @@ async def get_data_points(
|
|||||||
if not act.scalar_one_or_none():
|
if not act.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
|
||||||
q = select(ActivityDataPoint).where(
|
if downsample > 1:
|
||||||
ActivityDataPoint.activity_id == activity_id
|
# Stride in SQL (keep every Nth row by ordered row number) so the DB
|
||||||
).order_by(ActivityDataPoint.timestamp)
|
# 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)
|
result = await db.execute(q)
|
||||||
points = result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
if downsample > 1:
|
|
||||||
points = points[::downsample]
|
|
||||||
|
|
||||||
return points
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{activity_id}/laps", response_model=List[LapOut])
|
@router.get("/{activity_id}/laps", response_model=List[LapOut])
|
||||||
@@ -214,20 +331,76 @@ async def get_lap_bests(
|
|||||||
if not act.named_route_id:
|
if not act.named_route_id:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Best per lap number across OTHER activities on the same route, so the
|
# This activity's laps, so we know each lap's distance.
|
||||||
# comparison is meaningful (excluding this activity from its own benchmark).
|
this_laps = (await db.execute(
|
||||||
rows = (await db.execute(
|
select(ActivityLap.lap_number, ActivityLap.distance_m)
|
||||||
select(ActivityLap.lap_number, func.min(ActivityLap.duration_s))
|
.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)
|
.join(Activity, Activity.id == ActivityLap.activity_id)
|
||||||
.where(
|
.where(
|
||||||
Activity.named_route_id == act.named_route_id,
|
Activity.named_route_id == act.named_route_id,
|
||||||
Activity.user_id == current_user.id,
|
Activity.user_id == current_user.id,
|
||||||
Activity.id != activity_id,
|
Activity.id != activity_id,
|
||||||
ActivityLap.duration_s.isnot(None),
|
ActivityLap.duration_s.isnot(None),
|
||||||
|
ActivityLap.distance_m.isnot(None),
|
||||||
)
|
)
|
||||||
.group_by(ActivityLap.lap_number)
|
|
||||||
)).all()
|
)).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")
|
@router.get("/{activity_id}/route-leaderboard")
|
||||||
@@ -250,17 +423,21 @@ async def get_route_leaderboard(
|
|||||||
if not act.named_route_id:
|
if not act.named_route_id:
|
||||||
return None
|
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(
|
rows = (await db.execute(
|
||||||
select(
|
select(
|
||||||
Activity.id, Activity.name, Activity.start_time,
|
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(
|
.where(
|
||||||
Activity.named_route_id == act.named_route_id,
|
Activity.named_route_id == act.named_route_id,
|
||||||
Activity.user_id == current_user.id,
|
Activity.user_id == current_user.id,
|
||||||
Activity.duration_s.isnot(None),
|
Activity.duration_s.isnot(None),
|
||||||
)
|
)
|
||||||
.order_by(Activity.duration_s)
|
.order_by(effort_s)
|
||||||
)).all()
|
)).all()
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
@@ -310,9 +487,23 @@ async def rename_activity(
|
|||||||
if not activity:
|
if not activity:
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
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()
|
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)
|
@router.delete("/{activity_id}", status_code=204)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from jose import jwt, JWTError
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.database import get_db
|
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.core.config import settings
|
||||||
from app.models.user import User
|
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))
|
result = await db.execute(select(User).where(User.username == form_data.username))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
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")
|
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||||
if user.pocketid_sub is not None:
|
if user.pocketid_sub is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ class HealthMetricOut(BaseModel):
|
|||||||
max_hr_day: Optional[float]
|
max_hr_day: Optional[float]
|
||||||
avg_hr_day: Optional[float]
|
avg_hr_day: Optional[float]
|
||||||
hrv_nightly_avg: Optional[float]
|
hrv_nightly_avg: Optional[float]
|
||||||
|
hrv_weekly_avg: Optional[float]
|
||||||
hrv_status: Optional[str]
|
hrv_status: Optional[str]
|
||||||
hrv_5min_high: Optional[float]
|
hrv_5min_high: Optional[float]
|
||||||
hrv_5min_low: Optional[float]
|
hrv_5min_low: Optional[float]
|
||||||
|
hrv_baseline_low: Optional[float]
|
||||||
|
hrv_baseline_upper: Optional[float]
|
||||||
sleep_duration_s: Optional[float]
|
sleep_duration_s: Optional[float]
|
||||||
sleep_deep_s: Optional[float]
|
sleep_deep_s: Optional[float]
|
||||||
sleep_light_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.database import get_db
|
||||||
from app.core.security import get_current_user, hash_password, verify_password
|
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
|
from app.models.user import User, WeightLog
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -36,6 +37,8 @@ class ProfileOut(BaseModel):
|
|||||||
estimated_max_hr: Optional[int]
|
estimated_max_hr: Optional[int]
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
dashboard_layout: Optional[list] = None
|
dashboard_layout: Optional[list] = None
|
||||||
|
map_settings: Optional[dict] = None
|
||||||
|
thunderforest_default_key: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -45,6 +48,12 @@ class DashboardLayoutIn(BaseModel):
|
|||||||
layout: Optional[list] = None # react-grid-layout array of {i,x,y,w,h}
|
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]:
|
def _estimated_max_hr(user: User) -> Optional[int]:
|
||||||
if user.birth_year:
|
if user.birth_year:
|
||||||
return 220 - (datetime.now().year - 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)):
|
async def get_profile(current_user: User = Depends(get_current_user)):
|
||||||
return {**{c.name: getattr(current_user, c.name)
|
return {**{c.name: getattr(current_user, c.name)
|
||||||
for c in User.__table__.columns},
|
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")
|
@router.put("/dashboard-layout")
|
||||||
@@ -111,7 +140,8 @@ async def update_profile(
|
|||||||
|
|
||||||
return {**{c.name: getattr(current_user, c.name)
|
return {**{c.name: getattr(current_user, c.name)
|
||||||
for c in User.__table__.columns},
|
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 ────────────────────────────────────────────────────────
|
# ── Password change ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class RouteOut(BaseModel):
|
|||||||
auto_detected: Optional[bool]
|
auto_detected: Optional[bool]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
activity_count: int = 0
|
activity_count: int = 0
|
||||||
|
last_activity_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -45,24 +46,33 @@ async def list_routes(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
# Fetch routes with activity counts in one query
|
# Fetch routes with activity counts and last-completed time in one query
|
||||||
count_subq = (
|
agg_subq = (
|
||||||
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
|
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))
|
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
|
||||||
.group_by(Activity.named_route_id)
|
.group_by(Activity.named_route_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
|
select(
|
||||||
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
|
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)
|
.where(NamedRoute.user_id == current_user.id)
|
||||||
.order_by(desc(NamedRoute.created_at))
|
.order_by(desc(NamedRoute.created_at))
|
||||||
)
|
)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
out = []
|
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 = {c.name: getattr(route, c.name) for c in route.__table__.columns}
|
||||||
d["activity_count"] = cnt
|
d["activity_count"] = cnt
|
||||||
|
d["last_activity_at"] = last_at
|
||||||
out.append(RouteOut(**d))
|
out.append(RouteOut(**d))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -169,6 +179,9 @@ async def update_route(
|
|||||||
raise HTTPException(status_code=404, detail="Route not found")
|
raise HTTPException(status_code=404, detail="Route not found")
|
||||||
if body.name is not None and body.name.strip():
|
if body.name is not None and body.name.strip():
|
||||||
route.name = 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:
|
if body.sport_type is not None:
|
||||||
route.sport_type = body.sport_type
|
route.sport_type = body.sport_type
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -182,11 +195,13 @@ async def route_activities(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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(
|
result = await db.execute(
|
||||||
select(Activity).where(
|
select(Activity).where(
|
||||||
Activity.named_route_id == route_id,
|
Activity.named_route_id == route_id,
|
||||||
Activity.user_id == current_user.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()
|
activities = result.scalars().all()
|
||||||
return [
|
return [
|
||||||
@@ -194,7 +209,7 @@ async def route_activities(
|
|||||||
"id": a.id,
|
"id": a.id,
|
||||||
"name": a.name,
|
"name": a.name,
|
||||||
"start_time": a.start_time,
|
"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,
|
"distance_m": a.distance_m,
|
||||||
"avg_heart_rate": a.avg_heart_rate,
|
"avg_heart_rate": a.avg_heart_rate,
|
||||||
"avg_speed_ms": a.avg_speed_ms,
|
"avg_speed_ms": a.avg_speed_ms,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class SegmentCreate(BaseModel):
|
|||||||
end_distance_m: float
|
end_distance_m: float
|
||||||
|
|
||||||
|
|
||||||
|
class SegmentUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class EffortOut(BaseModel):
|
class EffortOut(BaseModel):
|
||||||
activity_id: int
|
activity_id: int
|
||||||
activity_name: str
|
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)
|
@router.delete("/{segment_id}", status_code=204)
|
||||||
async def delete_segment(
|
async def delete_segment(
|
||||||
segment_id: int,
|
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 pathlib import Path
|
||||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.user import User
|
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()
|
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)
|
MAX_EXTRACT_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB total uncompressed cap (zip-bomb guard)
|
||||||
_CHUNK = 1024 * 1024
|
_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:
|
def _safe_name(filename: str) -> str:
|
||||||
"""Reduce an uploaded filename to a safe basename — no path traversal."""
|
"""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
|
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")
|
@router.post("/activity")
|
||||||
async def upload_activity(
|
async def upload_activity(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -81,16 +137,17 @@ async def upload_activity(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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()
|
suffix = Path(file.filename).suffix.lower()
|
||||||
if suffix not in {".fit", ".gpx"}:
|
if suffix not in {".fit", ".gpx", ".tcx"}:
|
||||||
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
|
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_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
|
||||||
dest = save_upload(file, dest_dir)
|
dest = save_upload(file, dest_dir)
|
||||||
|
|
||||||
# Queue processing
|
# Queue processing
|
||||||
task = process_activity_file.delay(str(dest), current_user.id, suffix[1:])
|
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}
|
return {"task_id": task.id, "status": "queued", "filename": file.filename}
|
||||||
|
|
||||||
@@ -152,6 +209,7 @@ async def upload_garmin_export(
|
|||||||
|
|
||||||
# Queue health/wellness data extraction
|
# Queue health/wellness data extraction
|
||||||
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
||||||
|
_remember_task_owner(health_task.id, current_user.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "queued",
|
"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")
|
@router.post("/strava-export")
|
||||||
async def upload_strava_export(
|
async def upload_strava_export(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
dry_run: bool = False,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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"):
|
if not file.filename.endswith(".zip"):
|
||||||
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
|
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}"
|
extract_dir = dest_dir / f"strava_{dest.stem}"
|
||||||
|
|
||||||
task_ids = []
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(dest) as zf:
|
with zipfile.ZipFile(dest) as zf:
|
||||||
extracted = _safe_extract(zf, extract_dir)
|
extracted = _safe_extract(zf, extract_dir)
|
||||||
@@ -183,31 +266,74 @@ async def upload_strava_export(
|
|||||||
dest.unlink(missing_ok=True)
|
dest.unlink(missing_ok=True)
|
||||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||||
|
|
||||||
for path in extracted:
|
files = _collect_activity_files(extracted)
|
||||||
suffix = path.suffix.lower()
|
if not files:
|
||||||
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:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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 {
|
return {
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"activity_tasks": len(task_ids),
|
"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}")
|
@router.get("/task/{task_id}")
|
||||||
async def check_task_status(
|
async def check_task_status(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Check the status of an upload processing task."""
|
"""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
|
from app.workers.celery_app import celery_app
|
||||||
result = celery_app.AsyncResult(task_id)
|
result = celery_app.AsyncResult(task_id)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ from app.core.security import get_current_user
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.user import (
|
from app.models.user import (
|
||||||
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
|
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
|
||||||
Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
|
Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog,
|
||||||
|
GarminConnectConfig, StravaConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
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(HealthMetric).where(HealthMetric.user_id == user_id))
|
||||||
await db.execute(delete(WeightLog).where(WeightLog.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))
|
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.execute(delete(User).where(User.id == user_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ class Settings(BaseSettings):
|
|||||||
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
|
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
|
||||||
# Garmin Connect — how often the beat scheduler runs the automatic sync
|
# Garmin Connect — how often the beat scheduler runs the automatic sync
|
||||||
garmin_sync_interval_minutes: int = Field(30, env="GARMIN_SYNC_INTERVAL_MINUTES")
|
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
|
# Files
|
||||||
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
||||||
# Environment
|
# Environment
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ def verify_password(plain: str, hashed: str) -> bool:
|
|||||||
return pwd_context.verify(plain, hashed)
|
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:
|
def hash_password(password: str) -> str:
|
||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|||||||
+46
-1
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
|
|
||||||
from app.core.database import engine, AsyncSessionLocal, Base
|
from app.core.database import engine, AsyncSessionLocal, Base
|
||||||
from app.core.config import settings
|
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():
|
async def init_db():
|
||||||
@@ -56,6 +56,12 @@ async def init_db():
|
|||||||
await conn.execute(text(
|
await conn.execute(text(
|
||||||
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS moving_time_s FLOAT"
|
"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:
|
except Exception as e:
|
||||||
print(f"activities.moving_time_s column migration skipped: {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 intraday_hr JSONB",
|
||||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery 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 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))
|
await conn.execute(text(stmt))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -109,6 +118,15 @@ async def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"users.dashboard_layout column migration skipped: {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
|
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
|
||||||
try:
|
try:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
@@ -180,6 +198,20 @@ async def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"avg_speed_ms fix skipped: {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)
|
# Seed admin user (only if password is configured)
|
||||||
if not settings.admin_password:
|
if not settings.admin_password:
|
||||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||||
@@ -231,6 +263,18 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
|
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
|
||||||
app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
|
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(upload.router, prefix="/api/upload", tags=["upload"])
|
||||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
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(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(users.router, prefix="/api/users", tags=["users"])
|
||||||
app.include_router(segments.router, prefix="/api/segments", tags=["segments"])
|
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.
|
# Saved dashboard widget layout (react-grid-layout array). Null = use default.
|
||||||
dashboard_layout = Column(JSON, nullable=True)
|
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")
|
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||||
health_metrics = relationship("HealthMetric", 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")
|
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||||
weight_logs = relationship("WeightLog", 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")
|
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):
|
class GarminConnectConfig(Base):
|
||||||
@@ -67,6 +72,27 @@ class GarminConnectConfig(Base):
|
|||||||
user = relationship("User", back_populates="garmin_connect_config")
|
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):
|
class WeightLog(Base):
|
||||||
"""Manual weight entries separate from health_metrics for easy tracking."""
|
"""Manual weight entries separate from health_metrics for easy tracking."""
|
||||||
__tablename__ = "weight_logs"
|
__tablename__ = "weight_logs"
|
||||||
@@ -91,6 +117,7 @@ class Activity(Base):
|
|||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
name = Column(String(256), nullable=False)
|
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)
|
sport_type = Column(String(64), nullable=False)
|
||||||
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
end_time = Column(DateTime(timezone=True), nullable=True)
|
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -106,6 +133,11 @@ class Activity(Base):
|
|||||||
normalized_power = Column(Float, nullable=True)
|
normalized_power = Column(Float, nullable=True)
|
||||||
avg_speed_ms = Column(Float, nullable=True)
|
avg_speed_ms = Column(Float, nullable=True)
|
||||||
max_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)
|
avg_temperature_c = Column(Float, nullable=True)
|
||||||
calories = Column(Float, nullable=True)
|
calories = Column(Float, nullable=True)
|
||||||
training_stress_score = 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")
|
named_route = relationship("NamedRoute", back_populates="activities")
|
||||||
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
|
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):
|
class ActivityDataPoint(Base):
|
||||||
__tablename__ = "activity_data_points"
|
__tablename__ = "activity_data_points"
|
||||||
@@ -251,9 +293,12 @@ class HealthMetric(Base):
|
|||||||
max_hr_day = Column(Float, nullable=True)
|
max_hr_day = Column(Float, nullable=True)
|
||||||
avg_hr_day = Column(Float, nullable=True)
|
avg_hr_day = Column(Float, nullable=True)
|
||||||
hrv_status = Column(String(32), 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_high = Column(Float, nullable=True)
|
||||||
hrv_5min_low = 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_duration_s = Column(Float, nullable=True)
|
||||||
sleep_deep_s = Column(Float, nullable=True)
|
sleep_deep_s = Column(Float, nullable=True)
|
||||||
sleep_light_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
|
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):
|
def _bounding_box(coords):
|
||||||
if not coords:
|
if not coords:
|
||||||
return None
|
return None
|
||||||
@@ -241,9 +277,13 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
elapsed_s = _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time"))
|
elapsed_s = _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time"))
|
||||||
# Timer time = time the device was actively recording (excludes auto/manual pauses).
|
# Timer time = time the device was actively recording (excludes auto/manual pauses).
|
||||||
moving_s = _safe_float(get(session_data, "totalTimerTime", "total_timer_time"))
|
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(
|
avg_speed = _sanitize_speed(
|
||||||
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_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 {
|
return {
|
||||||
@@ -271,6 +311,7 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
"total_training_effect")),
|
"total_training_effect")),
|
||||||
"polyline": encoded_polyline,
|
"polyline": encoded_polyline,
|
||||||
"bounding_box": bounding_box,
|
"bounding_box": bounding_box,
|
||||||
|
"active_spans": _active_spans(normalized_points),
|
||||||
"source_type": "fit",
|
"source_type": "fit",
|
||||||
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
|
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
|
||||||
"data_points": normalized_points,
|
"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,
|
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
|
||||||
"training_stress_score": None, "vo2max_estimate": None,
|
"training_stress_score": None, "vo2max_estimate": None,
|
||||||
"polyline": encoded_polyline, "bounding_box": bounding_box,
|
"polyline": encoded_polyline, "bounding_box": bounding_box,
|
||||||
|
"active_spans": _active_spans(data_points),
|
||||||
"source_type": "gpx",
|
"source_type": "gpx",
|
||||||
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
|
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
|
||||||
"data_points": data_points, "laps": [],
|
"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:
|
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||||
if not user_max_hr or user_max_hr < 100:
|
if not user_max_hr or user_max_hr < 100:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -133,18 +133,24 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Slow-path dedup: activity imported via bulk export (no garmin_activity_id).
|
# 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.
|
# Match on the actual start instant (not just the date — two activities on
|
||||||
act_start_str = act.get("startTimeLocal") or act.get("startTimeGMT") or ""
|
# 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:
|
if act_start_str:
|
||||||
try:
|
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"))
|
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(
|
time_match = db.execute(
|
||||||
select(Activity).where(
|
select(Activity).where(
|
||||||
Activity.user_id == user_id,
|
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 time_match:
|
||||||
if not time_match.garmin_activity_id:
|
if not time_match.garmin_activity_id:
|
||||||
time_match.garmin_activity_id = garmin_id
|
time_match.garmin_activity_id = garmin_id
|
||||||
@@ -598,10 +604,15 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
|||||||
if hrv_data:
|
if hrv_data:
|
||||||
summary = hrv_data.get("hrvSummary") or 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_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"))
|
_set(row, "hrv_5min_high", summary.get("lastNight5MinHigh"))
|
||||||
status = summary.get("status")
|
status = summary.get("status")
|
||||||
if status:
|
if status:
|
||||||
row["hrv_status"] = str(status).lower()
|
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
|
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)
|
# Interval is configurable via GARMIN_SYNC_INTERVAL_MINUTES (default 30 min)
|
||||||
"schedule": float(settings.garmin_sync_interval_minutes * 60),
|
"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"]
|
parsed["moving_time_s"] = summary["moving"]
|
||||||
if summary.get("elapsed") is not None:
|
if summary.get("elapsed") is not None:
|
||||||
parsed["duration_s"] = summary["elapsed"]
|
parsed["duration_s"] = summary["elapsed"]
|
||||||
dur = parsed.get("duration_s")
|
# Recompute average speed over moving (timer) time, not elapsed wall-clock —
|
||||||
if parsed.get("distance_m") and dur:
|
# otherwise a long mid-activity pause (e.g. a meal stop on a ride) drags the
|
||||||
parsed["avg_speed_ms"] = parsed["distance_m"] / dur
|
# 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")
|
@celery_app.task(bind=True, name="process_activity_file")
|
||||||
def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
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.
|
"""Parse a FIT/GPX file. Routes wellness files to health parser.
|
||||||
|
|
||||||
`summary` (optional, from Garmin Connect sync) carries Garmin's corrected
|
`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):
|
if is_wellness_file(file_path):
|
||||||
parse_wellness_fit.delay(file_path, user_id)
|
parse_wellness_fit.delay(file_path, user_id)
|
||||||
return {"status": "routed_to_wellness", "file": file_path}
|
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.core.database import SyncSessionLocal
|
||||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
@@ -100,6 +114,8 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
|||||||
try:
|
try:
|
||||||
if source_type == "fit" or file_path.endswith(".fit"):
|
if source_type == "fit" or file_path.endswith(".fit"):
|
||||||
parsed = parse_fit_file(file_path)
|
parsed = parse_fit_file(file_path)
|
||||||
|
elif source_type == "tcx" or file_path.endswith(".tcx"):
|
||||||
|
parsed = parse_tcx_file(file_path)
|
||||||
else:
|
else:
|
||||||
parsed = parse_gpx_file(file_path)
|
parsed = parse_gpx_file(file_path)
|
||||||
except Exception as e:
|
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}
|
return {"status": "skipped", "reason": parsed["rejected_reason"], "file": file_path}
|
||||||
|
|
||||||
with SyncSessionLocal() as db:
|
with SyncSessionLocal() as db:
|
||||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
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
|
from datetime import timedelta
|
||||||
existing = db.execute(
|
|
||||||
select(Activity).where(
|
conds = [
|
||||||
Activity.user_id == user_id,
|
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),
|
||||||
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:
|
if existing:
|
||||||
# Stamp garmin_activity_id if this came from a Garmin Connect sync
|
# Stamp the external id so future syncs skip straight to the fast path.
|
||||||
# so future syncs skip the fast-path dedup and don't re-download.
|
stamped = False
|
||||||
if garmin_activity_id and not existing.garmin_activity_id:
|
if garmin_activity_id and not existing.garmin_activity_id:
|
||||||
existing.garmin_activity_id = 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()
|
db.commit()
|
||||||
return {"activity_id": existing.id, "status": "duplicate"}
|
return {"activity_id": existing.id, "status": "duplicate"}
|
||||||
|
|
||||||
# Get user max HR for zone calculation
|
# Get user max HR for zone calculation
|
||||||
from app.models.user import User as UserModel
|
|
||||||
user_obj = db.execute(
|
user_obj = db.execute(
|
||||||
select(UserModel).where(UserModel.id == user_id)
|
select(UserModel).where(UserModel.id == user_id)
|
||||||
).scalar_one_or_none()
|
).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"],
|
name=parsed["name"],
|
||||||
sport_type=parsed["sport_type"],
|
sport_type=parsed["sport_type"],
|
||||||
garmin_activity_id=garmin_activity_id,
|
garmin_activity_id=garmin_activity_id,
|
||||||
|
strava_activity_id=str(strava_activity_id) if strava_activity_id else None,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
distance_m=parsed.get("distance_m"),
|
distance_m=parsed.get("distance_m"),
|
||||||
duration_s=parsed.get("duration_s"),
|
duration_s=parsed.get("duration_s"),
|
||||||
@@ -174,12 +243,13 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
|||||||
normalized_power=parsed.get("normalized_power"),
|
normalized_power=parsed.get("normalized_power"),
|
||||||
avg_speed_ms=parsed.get("avg_speed_ms"),
|
avg_speed_ms=parsed.get("avg_speed_ms"),
|
||||||
max_speed_ms=parsed.get("max_speed_ms"),
|
max_speed_ms=parsed.get("max_speed_ms"),
|
||||||
|
active_spans=parsed.get("active_spans"),
|
||||||
avg_temperature_c=parsed.get("avg_temperature_c"),
|
avg_temperature_c=parsed.get("avg_temperature_c"),
|
||||||
calories=parsed.get("calories"),
|
calories=parsed.get("calories"),
|
||||||
training_stress_score=parsed.get("training_stress_score"),
|
training_stress_score=parsed.get("training_stress_score"),
|
||||||
polyline=parsed.get("polyline"),
|
polyline=parsed.get("polyline"),
|
||||||
bounding_box=parsed.get("bounding_box"),
|
bounding_box=parsed.get("bounding_box"),
|
||||||
source_file=file_path,
|
source_file=source_file,
|
||||||
source_type=parsed.get("source_type"),
|
source_type=parsed.get("source_type"),
|
||||||
hr_zones=hr_zones,
|
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"}
|
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")
|
@celery_app.task(name="parse_wellness_fit")
|
||||||
def parse_wellness_fit(file_path: str, user_id: int):
|
def parse_wellness_fit(file_path: str, user_id: int):
|
||||||
"""Parse a Garmin wellness FIT file and upsert into health_metrics."""
|
"""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"}
|
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")
|
@celery_app.task(name="compute_personal_records")
|
||||||
def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
|
def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
|
||||||
"""Calculate personal records for standard distances from this activity."""
|
"""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 sqlalchemy import select
|
||||||
from datetime import datetime, timezone
|
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", [])
|
data_points = parsed.get("data_points", [])
|
||||||
total_dist = parsed.get("distance_m", 0) or 0
|
total_dist = parsed.get("distance_m", 0) or 0
|
||||||
sport = parsed.get("sport_type", "running")
|
sport = parsed.get("sport_type", "running")
|
||||||
@@ -835,6 +973,96 @@ def sync_all_garmin_connect():
|
|||||||
return {"dispatched": len(user_ids)}
|
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")
|
@celery_app.task(name="recalculate_hr_zones_for_user")
|
||||||
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
|
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."""
|
"""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")
|
@celery_app.task(name="recompute_personal_records_all")
|
||||||
def recompute_personal_records_all(user_id: int):
|
def recompute_personal_records_all(user_id: int):
|
||||||
"""Wipe and rebuild all personal records from stored activity data, excluding
|
"""Wipe and rebuild all personal records from stored activity data, excluding
|
||||||
indoor (no-GPS) runs whose distance is unreliable. Fixes records polluted by
|
indoor (no-GPS) runs and non-watch (GPX/TCX) imports whose distance is
|
||||||
treadmill over-measurement and any duplicate current-record rows."""
|
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.services.route_matcher import compute_best_splits, STANDARD_DISTANCES
|
||||||
from app.core.database import SyncSessionLocal
|
from app.core.database import SyncSessionLocal
|
||||||
from app.models.user import Activity, ActivityDataPoint, PersonalRecord
|
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}
|
best = {} # (sport, dist_m) -> {dur, aid, at, label}
|
||||||
for a in acts:
|
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:
|
if a.sport_type == "running" and a.polyline is None:
|
||||||
continue # indoor/treadmill — unreliable distance
|
continue # indoor/treadmill — unreliable distance
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ services:
|
|||||||
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
||||||
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
||||||
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
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
|
FILE_STORE_PATH: /data/files
|
||||||
ENVIRONMENT: production
|
ENVIRONMENT: production
|
||||||
volumes:
|
volumes:
|
||||||
@@ -82,6 +85,8 @@ services:
|
|||||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
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
|
FILE_STORE_PATH: /data/files
|
||||||
volumes:
|
volumes:
|
||||||
- file_data:/data/files
|
- file_data:/data/files
|
||||||
@@ -100,6 +105,8 @@ services:
|
|||||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
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
|
FILE_STORE_PATH: /data/files
|
||||||
volumes:
|
volumes:
|
||||||
- file_data:/data/files
|
- file_data:/data/files
|
||||||
|
|||||||
+12
-10
@@ -10,7 +10,7 @@ services:
|
|||||||
POSTGRES_USER: ${DB_USER:-milevault}
|
POSTGRES_USER: ${DB_USER:-milevault}
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- ./db_data:/var/lib/postgresql/data
|
||||||
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
|
||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
|
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- ./redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -48,10 +48,13 @@ services:
|
|||||||
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
||||||
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
||||||
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
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
|
FILE_STORE_PATH: /data/files
|
||||||
ENVIRONMENT: ${ENVIRONMENT:-production}
|
ENVIRONMENT: ${ENVIRONMENT:-production}
|
||||||
volumes:
|
volumes:
|
||||||
- file_data:/data/files
|
- ./file_data:/data/files
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -74,9 +77,11 @@ services:
|
|||||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
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
|
FILE_STORE_PATH: /data/files
|
||||||
volumes:
|
volumes:
|
||||||
- file_data:/data/files
|
- ./file_data:/data/files
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -94,9 +99,11 @@ services:
|
|||||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
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
|
FILE_STORE_PATH: /data/files
|
||||||
volumes:
|
volumes:
|
||||||
- file_data:/data/files
|
- ./file_data:/data/files
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -124,8 +131,3 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- frontend
|
- 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
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
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)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
|
|||||||
Generated
+545
-1150
File diff suppressed because it is too large
Load Diff
@@ -11,23 +11,21 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.40.0",
|
"@tanstack/react-query": "^5.40.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-grid-layout": "^1.5.3",
|
"react-grid-layout": "^1.5.3",
|
||||||
"react-leaflet": "^4.2.1",
|
|
||||||
"react-router-dom": "^6.23.1",
|
"react-router-dom": "^6.23.1",
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.4",
|
"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 DashboardPage from './pages/DashboardPage'
|
||||||
import ActivitiesPage from './pages/ActivitiesPage'
|
import ActivitiesPage from './pages/ActivitiesPage'
|
||||||
import ActivityDetailPage from './pages/ActivityDetailPage'
|
import ActivityDetailPage from './pages/ActivityDetailPage'
|
||||||
|
import SummaryPage from './pages/SummaryPage'
|
||||||
import HealthPage from './pages/HealthPage'
|
import HealthPage from './pages/HealthPage'
|
||||||
import RoutesPage from './pages/RoutesPage'
|
import RoutesPage from './pages/RoutesPage'
|
||||||
import RecordsPage from './pages/RecordsPage'
|
import RecordsPage from './pages/RecordsPage'
|
||||||
@@ -33,8 +34,10 @@ export default function App() {
|
|||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="activities" element={<ActivitiesPage />} />
|
<Route path="activities" element={<ActivitiesPage />} />
|
||||||
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
||||||
|
<Route path="summary" element={<SummaryPage />} />
|
||||||
<Route path="health" element={<HealthPage />} />
|
<Route path="health" element={<HealthPage />} />
|
||||||
<Route path="routes" element={<RoutesPage />} />
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
|
<Route path="routes/:routeId" element={<RoutesPage />} />
|
||||||
<Route path="records" element={<RecordsPage />} />
|
<Route path="records" element={<RecordsPage />} />
|
||||||
<Route path="upload" element={<UploadPage />} />
|
<Route path="upload" element={<UploadPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { sportColor } from '../../utils/format'
|
import { sportColor } from '../../utils/format'
|
||||||
import { projectToTrack } from '../../utils/track'
|
import { projectToTrack } from '../../utils/track'
|
||||||
|
import { useResolvedTile } from '../../hooks/useMapSettings'
|
||||||
|
|
||||||
delete L.Icon.Default.prototype._getIconUrl
|
delete L.Icon.Default.prototype._getIconUrl
|
||||||
L.Icon.Default.mergeOptions({
|
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',
|
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
|
// 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.
|
// 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).
|
// Slow → fast colour ramp for speed-coloured routes (red → purple).
|
||||||
export const SPEED_STOPS = ['#ef4444', '#f97316', '#22c55e', '#3b82f6', '#a855f7']
|
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] })
|
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 mapRef = useRef(null)
|
||||||
const mapInstanceRef = useRef(null)
|
const mapInstanceRef = useRef(null)
|
||||||
const markerRef = useRef(null)
|
const markerRef = useRef(null)
|
||||||
@@ -167,10 +154,6 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
|||||||
preferCanvas: true,
|
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) => {
|
mapInstanceRef.current.on('click', (e) => {
|
||||||
if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng })
|
if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng })
|
||||||
})
|
})
|
||||||
@@ -209,11 +192,11 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapInstanceRef.current) return
|
if (!mapInstanceRef.current) return
|
||||||
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street
|
|
||||||
if (tileLayerRef.current) tileLayerRef.current.remove()
|
if (tileLayerRef.current) tileLayerRef.current.remove()
|
||||||
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
|
tileLayerRef.current = L.tileLayer(tile.url, {
|
||||||
.addTo(mapInstanceRef.current)
|
attribution: tile.attribution, maxZoom: tile.maxZoom, subdomains: tile.subdomains, ...TILE_OPTS,
|
||||||
}, [mapType])
|
}).addTo(mapInstanceRef.current)
|
||||||
|
}, [tile])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapInstanceRef.current) return
|
if (!mapInstanceRef.current) return
|
||||||
|
|||||||
@@ -2,9 +2,28 @@ import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCade
|
|||||||
|
|
||||||
const RUNNING_TYPES = new Set(['running', 'hiking', 'walking'])
|
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 showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
|
||||||
const hasBests = lapBests && Object.keys(lapBests).length > 0
|
const hasBests = lapBests && Object.keys(lapBests).length > 0
|
||||||
|
const modal = modalDistance(laps)
|
||||||
|
const prs = records || []
|
||||||
|
const showLegend = hasBests || prs.length > 0
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -23,25 +42,46 @@ export default function LapTable({ laps, sportType, lapBests }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{laps.map((lap) => {
|
{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 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 (
|
return (
|
||||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
<tr
|
||||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
key={lap.lap_number}
|
||||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
className={`border-b transition-colors ${
|
||||||
<td className={`py-2 text-right ${isBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
|
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 && (
|
{hasBests && (
|
||||||
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
|
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
|
||||||
)}
|
)}
|
||||||
{hasBests && (
|
{hasBests && (
|
||||||
<td className={`py-2 text-right font-mono ${
|
<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>
|
||||||
)}
|
)}
|
||||||
<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">
|
<td className="py-2 text-right">
|
||||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -58,6 +98,12 @@ export default function LapTable({ laps, sportType, lapBests }) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip,
|
ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} 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 cadence colour bands (steps per minute). Cadence is stored halved for
|
||||||
// running, so spm = stored × 2.
|
// 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 (!active || !payload?.length) return null
|
||||||
if (onHover) onHover(label)
|
if (onHover) onHover(label)
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
|
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
|
||||||
<p className="text-gray-400 mb-1">{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 => {
|
{payload.map(entry => {
|
||||||
const metric = metrics.find(m => m.key === entry.dataKey)
|
const metric = metrics.find(m => m.key === entry.dataKey)
|
||||||
if (!metric || entry.value == null) return null
|
if (!metric || entry.value == null) return null
|
||||||
let display = entry.value.toFixed(1)
|
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 === 'heart_rate') display = `${Math.round(entry.value)} bpm`
|
||||||
else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType)
|
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 === 'power') display = `${Math.round(entry.value)} W`
|
||||||
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
|
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 (
|
return (
|
||||||
<div key={entry.dataKey} className="flex items-center gap-2">
|
<div key={entry.dataKey} className="flex items-center gap-2">
|
||||||
<span style={{ color: entry.color }}>●</span>
|
<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
|
// Stationary/indoor activities (HIIT, strength, trainer) record no distance, so
|
||||||
// plotting against distance collapses every sample onto x=0. Fall back to an
|
// plotting against distance collapses every sample onto x=0. Fall back to an
|
||||||
// elapsed-time X-axis when there's no distance spread.
|
// elapsed-time X-axis when there's no distance spread.
|
||||||
@@ -142,7 +142,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
dataKey="x"
|
dataKey="x"
|
||||||
type="number"
|
type="number"
|
||||||
domain={['dataMin', 'dataMax']}
|
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' }}
|
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -157,8 +157,8 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
tickFormatter={v => {
|
tickFormatter={v => {
|
||||||
if (metric.key === 'speed_ms') {
|
if (metric.key === 'speed_ms') {
|
||||||
if (v <= 0 || v > 25) return ''
|
if (v <= 0 || v > 25) return ''
|
||||||
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
if (sportType === 'cycling') return `${(unit === 'mi' ? v * 2.2369363 : v * 3.6).toFixed(0)}`
|
||||||
const spm = 1000 / v
|
const spm = (unit === 'mi' ? 1609.344 : 1000) / v
|
||||||
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
|
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))
|
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
|
<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}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
{metric.key === 'cadence' && sportType === 'running' ? (
|
{metric.key === 'cadence' && sportType === 'running' ? (
|
||||||
@@ -191,7 +191,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../../utils/api'
|
import api from '../../utils/api'
|
||||||
import { formatDuration, formatDistance } from '../../utils/format'
|
import { formatDuration, formatDistance } from '../../utils/format'
|
||||||
|
import { useUnit } from '../../hooks/useUnits'
|
||||||
|
|
||||||
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
|
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
|
||||||
const PLACE_MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
const PLACE_MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||||
@@ -72,7 +73,10 @@ function Leaderboard({ segmentId, activityId }) {
|
|||||||
|
|
||||||
export default function SegmentsPanel({ segments, activityId }) {
|
export default function SegmentsPanel({ segments, activityId }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const unit = useUnit()
|
||||||
const [open, setOpen] = useState(null)
|
const [open, setOpen] = useState(null)
|
||||||
|
const [editingId, setEditingId] = useState(null)
|
||||||
|
const [editName, setEditName] = useState('')
|
||||||
|
|
||||||
const remove = async (id) => {
|
const remove = async (id) => {
|
||||||
if (!confirm('Delete this segment?')) return
|
if (!confirm('Delete this segment?')) return
|
||||||
@@ -80,6 +84,20 @@ export default function SegmentsPanel({ segments, activityId }) {
|
|||||||
qc.invalidateQueries()
|
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 (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<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"
|
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
|
||||||
>
|
>
|
||||||
<td className="py-2">
|
<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
|
<button
|
||||||
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
|
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
|
||||||
className="text-left text-gray-300 hover:text-white"
|
className="text-left text-gray-300 hover:text-white"
|
||||||
>
|
>
|
||||||
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
|
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
|
||||||
{seg.name}
|
{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>
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
||||||
{formatDuration(seg.duration_s)}
|
{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-500">+{formatDuration(delta)}</span>
|
||||||
: <span className="text-gray-700">--</span>}
|
: <span className="text-gray-700">--</span>}
|
||||||
</td>
|
</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>
|
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment">✕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../../hooks/useAuth'
|
import { useAuthStore } from '../../hooks/useAuth'
|
||||||
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
||||||
|
import { useHydrateMapSettings } from '../../hooks/useMapSettings'
|
||||||
|
import UnitToggle from './UnitToggle'
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true },
|
{ to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true },
|
||||||
{ to: '/activities', label: 'Activities', icon: '🏃', mobilePrimary: true },
|
{ to: '/activities', label: 'Activities', icon: '🏃', mobilePrimary: true },
|
||||||
|
{ to: '/summary', label: 'Summary', icon: '📈' },
|
||||||
{ to: '/health', label: 'Health', icon: '❤️', mobilePrimary: true },
|
{ to: '/health', label: 'Health', icon: '❤️', mobilePrimary: true },
|
||||||
{ to: '/routes', label: 'Routes', icon: '🗺️', mobilePrimary: true },
|
{ to: '/routes', label: 'Routes', icon: '🗺️', mobilePrimary: true },
|
||||||
{ to: '/records', label: 'Records', icon: '🏆' },
|
{ to: '/records', label: 'Records', icon: '🏆' },
|
||||||
@@ -22,6 +25,9 @@ export default function Layout() {
|
|||||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
|
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
const [moreOpen, setMoreOpen] = useState(false)
|
||||||
|
|
||||||
|
// Load the user's saved map tile preference from the server into the store.
|
||||||
|
useHydrateMapSettings()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startPolling()
|
startPolling()
|
||||||
return () => stopPolling()
|
return () => stopPolling()
|
||||||
@@ -106,6 +112,14 @@ export default function Layout() {
|
|||||||
</div>
|
</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 */}
|
{/* Logged-in user + privilege level */}
|
||||||
<div className="border-t border-gray-800 p-3">
|
<div className="border-t border-gray-800 p-3">
|
||||||
{user ? (
|
{user ? (
|
||||||
@@ -146,6 +160,7 @@ export default function Layout() {
|
|||||||
<span className="text-blue-400">Mile</span>Vault
|
<span className="text-blue-400">Mile</span>Vault
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<UnitToggle />
|
||||||
{inProgress && (
|
{inProgress && (
|
||||||
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400 animate-pulse"
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400 animate-pulse"
|
||||||
title={`Garmin sync: ${status || 'starting…'}`} />
|
title={`Garmin sync: ${status || 'starting…'}`} />
|
||||||
|
|||||||
@@ -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',
|
green: 'text-green-400',
|
||||||
orange: 'text-orange-400',
|
orange: 'text-orange-400',
|
||||||
purple: 'text-purple-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 (
|
return (
|
||||||
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50 h-full flex flex-col justify-center">
|
<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-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>}
|
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
|
||||||
</div>
|
</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])
|
||||||
|
}
|
||||||
@@ -39,17 +39,42 @@ export const useSyncStore = create((set, get) => ({
|
|||||||
connected: false,
|
connected: false,
|
||||||
lastSyncAt: null,
|
lastSyncAt: null,
|
||||||
email: '',
|
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 () => {
|
poll: async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/garmin-sync/config')
|
const { data } = await api.get('/garmin-sync/config')
|
||||||
const status = data?.last_sync_status ?? ''
|
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({
|
set({
|
||||||
status, inProgress,
|
status, inProgress,
|
||||||
connected: !!data?.connected,
|
connected: !!data?.connected,
|
||||||
lastSyncAt: data?.last_sync_at ?? null,
|
lastSyncAt, email: data?.email ?? '',
|
||||||
email: data?.email ?? '',
|
|
||||||
})
|
})
|
||||||
return inProgress
|
return inProgress
|
||||||
} catch {
|
} catch {
|
||||||
@@ -74,11 +99,11 @@ export const useSyncStore = create((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
trigger: async () => {
|
trigger: async () => {
|
||||||
set({ inProgress: true, status: 'Starting sync…' })
|
set({ inProgress: true, status: 'Starting sync…', triggeredAt: Date.now(), prevSyncAt: get().lastSyncAt })
|
||||||
try {
|
try {
|
||||||
await api.post('/garmin-sync/trigger')
|
await api.post('/garmin-sync/trigger')
|
||||||
} catch {
|
} catch {
|
||||||
set({ inProgress: false })
|
set({ inProgress: false, triggeredAt: null })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
get().stopPolling()
|
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 { useState, useEffect } from 'react'
|
||||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { format } from 'date-fns'
|
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
|
||||||
formatDate, sportIcon, sportColor,
|
formatDate, sportColor, distanceUnitLabel,
|
||||||
} from '../utils/format'
|
} 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() {
|
export default function ActivitiesPage() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
const unit = useUnit()
|
||||||
const [sport, setSport] = useState('all')
|
const distLabel = distanceUnitLabel(unit)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
const fromParam = searchParams.get('from')
|
// Filters
|
||||||
const toParam = searchParams.get('to')
|
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({
|
const { data: activities, isLoading } = useQuery({
|
||||||
queryKey: ['activities', sport, page, fromParam, toParam],
|
queryKey: ['activities', sport, year, minDist, maxDist, fromDate, toDate, page, unit],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get('/activities/', {
|
api.get('/activities/', {
|
||||||
params: {
|
params: {
|
||||||
sport_type: sport === 'all' ? undefined : sport,
|
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,
|
page,
|
||||||
per_page: 20,
|
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),
|
}).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: ytdStats } = useQuery({
|
const anyFilter = sport !== 'all' || year !== 'all' || minDist || maxDist || fromDate || toDate
|
||||||
queryKey: ['ytd-stats'],
|
const clearFilters = () => {
|
||||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
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 (
|
return (
|
||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||||||
<Link
|
<div className="flex items-center gap-2">
|
||||||
to="/upload"
|
<Link to="/summary" className="bg-gray-800 hover:bg-gray-700 text-gray-200 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"
|
📈 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
|
+ Import
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* YTD stats */}
|
|
||||||
{ytdStats && (
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Date filter chip */}
|
{/* Sport filter chips */}
|
||||||
{fromParam && (
|
<div className="flex gap-2 mb-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
{['all', ...sportTypes].map(s => (
|
||||||
<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 => (
|
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => { setSport(s); setPage(1) }}
|
onClick={() => setSport(s)}
|
||||||
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors ${
|
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors inline-flex items-center gap-1.5 ${
|
||||||
sport === s
|
sport === s
|
||||||
? 'bg-blue-600 border-blue-600 text-white'
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
: 'border-gray-700 text-gray-400 hover:text-white hover:border-gray-500'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Activity list */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-gray-500 text-sm">Loading…</div>
|
<div className="text-gray-500 text-sm">Loading…</div>
|
||||||
@@ -104,10 +166,10 @@ export default function ActivitiesPage() {
|
|||||||
>
|
>
|
||||||
{/* Sport indicator */}
|
{/* Sport indicator */}
|
||||||
<div
|
<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' }}
|
style={{ backgroundColor: sportColor(activity.sport_type) + '22' }}
|
||||||
>
|
>
|
||||||
{sportIcon(activity.sport_type)}
|
<SportIcon sport={activity.sport_type} size={20} color={sportColor(activity.sport_type)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name + date */}
|
{/* Name + date */}
|
||||||
@@ -115,17 +177,20 @@ export default function ActivitiesPage() {
|
|||||||
<p className="font-medium text-white group-hover:text-blue-400 transition-colors truncate">
|
<p className="font-medium text-white group-hover:text-blue-400 transition-colors truncate">
|
||||||
{activity.name}
|
{activity.name}
|
||||||
</p>
|
</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>
|
<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 */}
|
{/* Compact metrics line — the full metrics column is hidden below sm */}
|
||||||
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
|
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
|
||||||
{formatDistance(activity.distance_m)} · {formatDuration(activity.duration_s)} · {formatPace(activity.avg_speed_ms, activity.sport_type)}
|
{formatDistance(activity.distance_m, unit)} · {formatDuration(activity.duration_s)} · {formatPace(activity.avg_speed_ms, activity.sport_type, unit)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics */}
|
{/* Metrics */}
|
||||||
<div className="hidden sm:flex items-center gap-6 text-sm">
|
<div className="hidden sm:flex items-center gap-6 text-sm">
|
||||||
<div className="text-right">
|
<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>
|
<p className="text-xs text-gray-600">distance</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
@@ -133,7 +198,7 @@ export default function ActivitiesPage() {
|
|||||||
<p className="text-xs text-gray-600">time</p>
|
<p className="text-xs text-gray-600">time</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<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>
|
<p className="text-xs text-gray-600">pace</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
@@ -142,7 +207,7 @@ export default function ActivitiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-gray-200 font-medium">
|
<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>
|
||||||
<p className="text-xs text-gray-600">elev</p>
|
<p className="text-xs text-gray-600">elev</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,7 +219,7 @@ export default function ActivitiesPage() {
|
|||||||
|
|
||||||
{activities?.length === 0 && (
|
{activities?.length === 0 && (
|
||||||
<div className="text-center py-16 text-gray-600">
|
<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-lg">No activities yet</p>
|
||||||
<p className="text-sm mt-1">
|
<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
|
<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 { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
@@ -11,13 +11,15 @@ import RouteLeaderboard from '../components/activity/RouteLeaderboard'
|
|||||||
import StatCard from '../components/ui/StatCard'
|
import StatCard from '../components/ui/StatCard'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatPace, formatElevation,
|
formatDuration, formatDistance, formatPace, formatElevation,
|
||||||
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
formatHeartRate, formatDateTime, formatCadence, sportColor,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
import { useUnit } from '../hooks/useUnits'
|
||||||
|
import SportIcon from '../components/ui/SportIcon'
|
||||||
|
|
||||||
import { projectToTrack } from '../utils/track'
|
import { projectToTrack } from '../utils/track'
|
||||||
|
|
||||||
const METRICS = [
|
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: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
||||||
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
|
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
|
||||||
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
|
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
|
||||||
@@ -27,14 +29,21 @@ const METRICS = [
|
|||||||
|
|
||||||
export default function ActivityDetailPage() {
|
export default function ActivityDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
const unit = useUnit()
|
||||||
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
||||||
const [hoveredDistance, setHoveredDistance] = useState(null)
|
const [hoveredDistance, setHoveredDistance] = useState(null)
|
||||||
const [mapHeight, setMapHeight] = useState(420)
|
const [mapHeight, setMapHeight] = useState(420)
|
||||||
const [mapType, setMapType] = useState('street')
|
const [satellite, setSatellite] = useState(false)
|
||||||
const [colorMode, setColorMode] = useState('speed')
|
const [colorMode, setColorMode] = useState('speed')
|
||||||
const [segCreate, setSegCreate] = useState(false)
|
const [segCreate, setSegCreate] = useState(false)
|
||||||
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
|
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
|
||||||
const [segName, setSegName] = useState('')
|
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 qc = useQueryClient()
|
||||||
|
|
||||||
const { data: activity, isLoading } = useQuery({
|
const { data: activity, isLoading } = useQuery({
|
||||||
@@ -66,6 +75,12 @@ export default function ActivityDetailPage() {
|
|||||||
enabled: !!activity?.named_route_id,
|
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({
|
const { data: routeBoard } = useQuery({
|
||||||
queryKey: ['route-leaderboard', id],
|
queryKey: ['route-leaderboard', id],
|
||||||
queryFn: () => api.get(`/activities/${id}/route-leaderboard`).then(r => r.data),
|
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) => {
|
const toggleMetric = (key) => {
|
||||||
setActiveMetrics(prev =>
|
setActiveMetrics(prev =>
|
||||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||||
@@ -125,27 +174,106 @@ export default function ActivityDetailPage() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-2xl">{sportIcon(activity.sport_type)}</span>
|
<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>
|
<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>
|
</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>
|
<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>
|
||||||
</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 */}
|
{/* Stats — all on one row */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-10 gap-3">
|
<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)} />
|
<StatCard label="Distance" value={formatDistance(activity.distance_m, unit)} />
|
||||||
<StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
|
<StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
|
||||||
sub={activity.moving_time_s ? 'moving' : undefined} />
|
sub={activity.moving_time_s ? 'moving' : undefined} />
|
||||||
{activity.moving_time_s != null && Math.abs(activity.moving_time_s - (activity.duration_s ?? 0)) >= 1 && (
|
{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="Elapsed" value={formatDuration(activity.duration_s)} />
|
||||||
)}
|
)}
|
||||||
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
|
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type, unit)} />
|
||||||
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m, unit)} />
|
||||||
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
<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="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||||
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
<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="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` : '--'} />
|
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
||||||
</div>
|
</div>
|
||||||
@@ -166,18 +294,15 @@ export default function ActivityDetailPage() {
|
|||||||
{/* Map toolbar */}
|
{/* Map toolbar */}
|
||||||
<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 justify-between gap-y-2 px-4 py-2 border-b border-gray-800">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">Map style:</span>
|
|
||||||
{['dark', 'street', 'satellite'].map(t => (
|
|
||||||
<button
|
<button
|
||||||
key={t}
|
onClick={() => setSatellite(s => !s)}
|
||||||
onClick={() => setMapType(t)}
|
title="Map tiles are chosen globally in Profile › Map & Tiles"
|
||||||
className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
|
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
||||||
mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
satellite ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t}
|
🛰 Satellite
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
{dataPoints?.length > 0 && (
|
{dataPoints?.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSegCreate(c => !c); setSegPoints([]); setSegName('') }}
|
onClick={() => { setSegCreate(c => !c); setSegPoints([]); setSegName('') }}
|
||||||
@@ -224,8 +349,8 @@ export default function ActivityDetailPage() {
|
|||||||
Click two points on the route to mark the segment start and end.
|
Click two points on the route to mark the segment start and end.
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-400">
|
<span className="text-gray-400">
|
||||||
Start: {segPoints[0] ? `${(segPoints[0].distance_m / 1000).toFixed(2)} km` : '—'}
|
Start: {segPoints[0] ? formatDistance(segPoints[0].distance_m, unit) : '—'}
|
||||||
{' · '}End: {segPoints[1] ? `${(segPoints[1].distance_m / 1000).toFixed(2)} km` : '—'}
|
{' · '}End: {segPoints[1] ? formatDistance(segPoints[1].distance_m, unit) : '—'}
|
||||||
</span>
|
</span>
|
||||||
{segPoints.length === 2 && (
|
{segPoints.length === 2 && (
|
||||||
<>
|
<>
|
||||||
@@ -253,7 +378,7 @@ export default function ActivityDetailPage() {
|
|||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
hoveredDistance={hoveredDistance}
|
hoveredDistance={hoveredDistance}
|
||||||
sportType={activity.sport_type}
|
sportType={activity.sport_type}
|
||||||
mapType={mapType}
|
satellite={satellite}
|
||||||
colorMode={colorMode}
|
colorMode={colorMode}
|
||||||
onMapClick={segCreate ? handleMapClick : undefined}
|
onMapClick={segCreate ? handleMapClick : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -297,9 +422,10 @@ export default function ActivityDetailPage() {
|
|||||||
<MetricTimeline
|
<MetricTimeline
|
||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
|
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}
|
onHoverDistance={setHoveredDistance}
|
||||||
sportType={activity.sport_type}
|
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>
|
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
|
||||||
@@ -314,7 +440,7 @@ export default function ActivityDetailPage() {
|
|||||||
{laps && laps.length > 0 && (
|
{laps && laps.length > 0 && (
|
||||||
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
||||||
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
|
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} records={activityRecords} unit={unit} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{routeBoard && routeBoard.top?.length > 0 && (
|
{routeBoard && routeBoard.top?.length > 0 && (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo, useState, useEffect, useRef } from 'react'
|
import { useMemo, useState, useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
BarChart, Bar, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import GridLayout, { WidthProvider } from 'react-grid-layout'
|
import GridLayout, { WidthProvider } from 'react-grid-layout'
|
||||||
import 'react-grid-layout/css/styles.css'
|
import 'react-grid-layout/css/styles.css'
|
||||||
@@ -11,35 +11,33 @@ import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } f
|
|||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
import StatCard from '../components/ui/StatCard'
|
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 ActivityMap from '../components/activity/ActivityMap'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||||
formatDate, sportIcon, sportColor, formatSleep,
|
formatDate, sportColor, formatSleep, convertKm, distanceUnitLabel,
|
||||||
} from '../utils/format'
|
} 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 Grid = WidthProvider(GridLayout)
|
||||||
|
|
||||||
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||||
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, color: '#fff' }
|
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.
|
// Compact single-stat widgets. val(health, ytdStats) → display string.
|
||||||
const STAT_DEFS = {
|
const STAT_DEFS = {
|
||||||
stat_steps: { label: 'Steps today', accent: 'green', sub: 'goal 10,000', val: h => h.steps != null ? h.steps.toLocaleString() : '--' },
|
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_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_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_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: 'blue', val: (h, y) => y ? `${y.running_km.toFixed(0)} km` : '--' },
|
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) => y ? `${y.cycling_km.toFixed(0)} km` : '--' },
|
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: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' },
|
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_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 : '--' },
|
stat_floors: { label: 'Floors climbed', accent: 'green', val: h => h.floors_climbed != null ? h.floors_climbed : '--' },
|
||||||
}
|
}
|
||||||
@@ -109,59 +107,15 @@ function Stat({ label, value }) {
|
|||||||
|
|
||||||
// ── Chart widgets ────────────────────────────────────────────────────────────
|
// ── Chart widgets ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd }) {
|
// Body Battery widget — wraps the shared BodyBatteryChart in a dashboard Card.
|
||||||
const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ t: ts, level }))
|
// The dashboard variant fills its grid cell (fill) and uses a compact y-axis.
|
||||||
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
|
function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd, activities }) {
|
||||||
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))]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Body Battery" viewHref="/health">
|
<Card title="Body Battery" viewHref="/health">
|
||||||
<div className="flex flex-col h-full">
|
<BodyBatteryChart
|
||||||
<div className="flex items-baseline gap-3 flex-wrap">
|
bb={bb} hires={hires} sleepStart={sleepStart} sleepEnd={sleepEnd} activities={activities}
|
||||||
{peak != null && <span className="text-3xl font-bold" style={{ color: bbLevelColor(peak) }}>{Math.round(peak)}</span>}
|
fill yTicks={[0, 50, 100]} yAxisWidth={26} leftMargin={0} iconSize={13}
|
||||||
{charged != null && <span className="text-sm font-semibold text-green-400">+{charged}</span>}
|
emptyText="No body battery data today" />
|
||||||
{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>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -186,24 +140,25 @@ function Sparkline({ data, dataKey, color, gradId, fmt }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Vo2MaxTrend({ health, recentHealth }) {
|
function Vo2MaxTrend({ health, recentHealth, profile }) {
|
||||||
const series = useMemo(
|
const series = useMemo(
|
||||||
() => [...(recentHealth || [])].filter(d => d.vo2max != null)
|
() => [...(recentHealth || [])].filter(d => d.vo2max != null)
|
||||||
.sort((a, b) => new Date(a.date) - new Date(b.date))
|
.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||||
.map(d => ({ date: d.date, v: d.vo2max })),
|
.map(d => ({ date: d.date, v: d.vo2max })),
|
||||||
[recentHealth],
|
[recentHealth],
|
||||||
)
|
)
|
||||||
|
const color = vo2Color(health.vo2max, profile?.birth_year, profile?.biological_sex)
|
||||||
return (
|
return (
|
||||||
<Card title="VO₂ Max" viewHref="/health">
|
<Card title="VO₂ Max" viewHref="/health">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-baseline gap-2">
|
<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>
|
<span className="text-xs text-gray-500">ml/kg/min</span>
|
||||||
</div>
|
</div>
|
||||||
{health.fitness_age != null && <p className="text-xs text-gray-500 mt-0.5">Fitness age {health.fitness_age}</p>}
|
{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">
|
<div className="flex-1 min-h-0 mt-2">
|
||||||
{series.length >= 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>}
|
: <p className="text-xs text-gray-600">Not enough history</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,12 +178,12 @@ function WeightMini({ recentHealth }) {
|
|||||||
<Card title="Weight" viewHref="/health">
|
<Card title="Weight" viewHref="/health">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-baseline gap-2">
|
<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>
|
<span className="text-xs text-gray-500">kg</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 mt-2">
|
<div className="flex-1 min-h-0 mt-2">
|
||||||
{series.length >= 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>}
|
: <p className="text-xs text-gray-600">Not enough history</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,19 +191,22 @@ function WeightMini({ recentHealth }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canonical sleep-stage palette — matches the Health page hypnogram/charts.
|
||||||
const SLEEP_STAGES = [
|
const SLEEP_STAGES = [
|
||||||
{ key: 'sleep_deep_s', label: 'Deep', color: '#3b82f6' },
|
{ key: 'sleep_deep_s', label: 'Deep', color: '#6366f1' },
|
||||||
{ key: 'sleep_rem_s', label: 'REM', color: '#8b5cf6' },
|
{ key: 'sleep_rem_s', label: 'REM', color: '#7c3aed' },
|
||||||
{ key: 'sleep_light_s', label: 'Light', color: '#60a5fa' },
|
{ key: 'sleep_light_s', label: 'Light', color: '#a78bfa' },
|
||||||
{ key: 'sleep_awake_s', label: 'Awake', color: '#6b7280' },
|
{ 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 total = SLEEP_STAGES.reduce((s, st) => s + (health[st.key] || 0), 0)
|
||||||
|
const hasHypnogram = health.sleep_start && health.sleep_end && sleepStages?.length
|
||||||
return (
|
return (
|
||||||
<Card title="Sleep" viewHref="/health">
|
<Card title="Sleep" viewHref="/health">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-baseline gap-3 flex-wrap">
|
<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 && (
|
{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>
|
<span className="text-sm text-gray-400">score <span className="text-white font-semibold">{Math.round(health.sleep_score)}</span></span>
|
||||||
)}
|
)}
|
||||||
@@ -271,10 +229,18 @@ function SleepDetail({ health }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null))}
|
) : null))}
|
||||||
</div>
|
</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>
|
<p className="text-xs text-gray-600 mt-3">No sleep stages for last night</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -283,6 +249,8 @@ const sportLabel = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Other')
|
|||||||
|
|
||||||
function WeeklyChart({ activities }) {
|
function WeeklyChart({ activities }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const unit = useUnit()
|
||||||
|
const distLabel = distanceUnitLabel(unit)
|
||||||
const { data, sports } = useMemo(() => {
|
const { data, sports } = useMemo(() => {
|
||||||
if (!activities?.length) return { data: [], sports: [] }
|
if (!activities?.length) return { data: [], sports: [] }
|
||||||
// Sports present, ordered by total distance (largest stacks at the bottom).
|
// Sports present, ordered by total distance (largest stacks at the bottom).
|
||||||
@@ -299,14 +267,14 @@ function WeeklyChart({ activities }) {
|
|||||||
const t = new Date(a.start_time)
|
const t = new Date(a.start_time)
|
||||||
if (t >= weekStart && t < weekEnd) row[a.sport_type] += (a.distance_m || 0) / 1000
|
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 row
|
||||||
})
|
})
|
||||||
return { data, sports }
|
return { data, sports }
|
||||||
}, [activities])
|
}, [activities, unit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Weekly distance (km)">
|
<Card title={`Weekly distance (${distLabel})`}>
|
||||||
{data.length ? (
|
{data.length ? (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
@@ -318,7 +286,7 @@ function WeeklyChart({ activities }) {
|
|||||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
<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)}`} />
|
<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)' }}
|
<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) => (
|
{sports.map((s, i) => (
|
||||||
<Bar key={s} dataKey={s} stackId="dist" fill={sportColor(s)} isAnimationActive={false}
|
<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]} />
|
radius={i === sports.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]} />
|
||||||
@@ -343,6 +311,7 @@ function WeeklyChart({ activities }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FeaturedActivity({ activity, segments }) {
|
function FeaturedActivity({ activity, segments }) {
|
||||||
|
const unit = useUnit()
|
||||||
if (!activity) return (
|
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>
|
<Card title="Latest activity"><div className="flex items-center justify-center h-full text-gray-600 text-sm">No activities yet</div></Card>
|
||||||
)
|
)
|
||||||
@@ -350,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="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 justify-between px-4 py-3 border-b border-gray-800">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<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">
|
<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>
|
<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>
|
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||||
@@ -361,12 +330,12 @@ function FeaturedActivity({ activity, segments }) {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-3 flex-1 min-h-0">
|
<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">
|
<div className="lg:col-span-2 min-h-[180px] bg-gray-950">
|
||||||
{activity.polyline
|
{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 className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
|
<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="Distance" value={formatDistance(activity.distance_m, unit)} />
|
||||||
<Stat label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
<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="Moving time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)} />
|
||||||
<Stat label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
<Stat label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||||
</div>
|
</div>
|
||||||
@@ -401,27 +370,60 @@ function FeaturedActivity({ activity, segments }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RecentActivities({ activities }) {
|
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 (
|
return (
|
||||||
<Card title="Recent activities" viewHref="/activities">
|
<Card title="Recent activities" viewHref="/activities">
|
||||||
<div className="space-y-2 overflow-auto h-full">
|
{activities?.length ? (
|
||||||
{activities?.slice(0, 6).map(activity => (
|
// 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}`}
|
<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">
|
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">
|
||||||
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
<SportIcon sport={activity.sport_type} size={20} color={sportColor(activity.sport_type)} className="shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
<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>
|
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-sm">
|
{!activity.named_route_id && activity.polyline && activity.distance_m > 0 && (
|
||||||
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p>
|
<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>
|
<p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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>
|
<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>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -483,6 +485,7 @@ export default function DashboardPage() {
|
|||||||
sleep_awake_s: latest.sleep_awake_s ?? null,
|
sleep_awake_s: latest.sleep_awake_s ?? null,
|
||||||
sleep_score: pick('sleep_score'),
|
sleep_score: pick('sleep_score'),
|
||||||
hrv_nightly_avg: pick('hrv_nightly_avg'),
|
hrv_nightly_avg: pick('hrv_nightly_avg'),
|
||||||
|
hrv_weekly_avg: pick('hrv_weekly_avg'),
|
||||||
hrv_status: pick('hrv_status'),
|
hrv_status: pick('hrv_status'),
|
||||||
steps: pick('steps'),
|
steps: pick('steps'),
|
||||||
vo2max: pick('vo2max'),
|
vo2max: pick('vo2max'),
|
||||||
@@ -514,6 +517,7 @@ export default function DashboardPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Layout state ──────────────────────────────────────────────────────────
|
// ── Layout state ──────────────────────────────────────────────────────────
|
||||||
|
const unit = useUnit()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [editMode, setEditMode] = useState(false)
|
const [editMode, setEditMode] = useState(false)
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
@@ -567,14 +571,18 @@ export default function DashboardPage() {
|
|||||||
const renderWidget = (id) => {
|
const renderWidget = (id) => {
|
||||||
if (STAT_DEFS[id]) {
|
if (STAT_DEFS[id]) {
|
||||||
const d = 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} />
|
sub={typeof d.sub === 'function' ? d.sub(health) : d.sub} />
|
||||||
}
|
}
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'weekly': return <WeeklyChart activities={allActivities} />
|
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 '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} />
|
case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} profile={profile} />
|
||||||
case 'sleepDetail': return <SleepDetail health={health} />
|
case 'sleepDetail': return <SleepDetail health={health} sleepStages={intraday?.sleep_stages} />
|
||||||
case 'weight': return <WeightMini recentHealth={recentHealth} />
|
case 'weight': return <WeightMini recentHealth={recentHealth} />
|
||||||
case 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
|
case 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
|
||||||
case 'recent': return <RecentActivities activities={recentActivities} />
|
case 'recent': return <RecentActivities activities={recentActivities} />
|
||||||
|
|||||||
+138
-313
@@ -1,13 +1,16 @@
|
|||||||
import { useState, useMemo, useRef } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine, ReferenceArea,
|
AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine,
|
||||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns'
|
import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { formatSleep, sportIcon } from '../utils/format'
|
import { formatSleep } from '../utils/format'
|
||||||
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
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 = [
|
const RANGES = [
|
||||||
{ label: '1W', days: 7 },
|
{ label: '1W', days: 7 },
|
||||||
@@ -22,42 +25,6 @@ const RANGES = [
|
|||||||
|
|
||||||
// ── VO2 Max gauge ────────────────────────────────────────────────────────────
|
// ── 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 }) {
|
function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
|
||||||
const MIN = 30, MAX = 70
|
const MIN = 30, MAX = 70
|
||||||
// cx/cy = centre of the semicircle; arc goes left→top→right (sweep=1, clockwise in SVG)
|
// 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
|
// ACSM category boundaries for this user's age/sex
|
||||||
const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE
|
const thresholds = vo2Thresholds(age, biologicalSex)
|
||||||
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
|
||||||
const thresholds = row[1]
|
|
||||||
const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands
|
const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands
|
||||||
|
|
||||||
const cat = value != null ? getVo2Category(value, age, biologicalSex) : null
|
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 }}>
|
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="grad-intraday-hr" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="grad-intraday-hr" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
|
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
@@ -173,7 +138,7 @@ function IntradayHrChart({ values }) {
|
|||||||
<Tooltip contentStyle={tooltipStyle}
|
<Tooltip contentStyle={tooltipStyle}
|
||||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
formatter={v => [`${Math.round(v)} bpm`, 'HR']} />
|
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} />
|
fill="url(#grad-intraday-hr)" dot={false} isAnimationActive={false} connectNulls={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -182,214 +147,14 @@ function IntradayHrChart({ values }) {
|
|||||||
|
|
||||||
// ── Body Battery ─────────────────────────────────────────────────────────────
|
// ── Body Battery ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ActivityRefLabel({ viewBox, icon }) {
|
// Health-page Body Battery panel — the shared BodyBatteryChart in this page's
|
||||||
if (!viewBox) return null
|
// card frame. Uses a fixed chart height (the panel has no intrinsic height).
|
||||||
const { x, y, width = 0 } = viewBox
|
function BodyBatteryPanel({ bb, hiresValues, sleepStart, sleepEnd, activities }) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full">
|
<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>
|
<h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3>
|
||||||
|
<BodyBatteryChart
|
||||||
<div className="flex items-baseline gap-3 flex-wrap mb-3">
|
bb={bb} hires={hiresValues} sleepStart={sleepStart} sleepEnd={sleepEnd} activities={activities} />
|
||||||
{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>
|
|
||||||
</div>
|
</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 }) {
|
function NavArrow({ onClick, disabled, children }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -458,10 +211,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
: day.avg_stress < 25 ? 'Restful'
|
: day.avg_stress < 25 ? 'Restful'
|
||||||
: day.avg_stress < 50 ? 'Low'
|
: day.avg_stress < 50 ? 'Low'
|
||||||
: day.avg_stress < 75 ? 'Medium' : 'High'
|
: day.avg_stress < 75 ? 'Medium' : 'High'
|
||||||
const stressColor = !day.avg_stress ? 'text-white'
|
const stressColor = day.avg_stress ? 'text-orange-400' : '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'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -483,13 +233,13 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
||||||
{day.sleep_score != null && (
|
{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)}
|
Score {Math.round(day.sleep_score)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<span className="text-4xl font-bold text-white tracking-tight">
|
<span className="text-4xl font-bold text-violet-400 tracking-tight">
|
||||||
{formatSleep(day.sleep_duration_s)}
|
{formatSleep(day.sleep_duration_s)}
|
||||||
</span>
|
</span>
|
||||||
{day.sleep_start && day.sleep_end && (
|
{day.sleep_start && day.sleep_end && (
|
||||||
@@ -538,7 +288,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<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) : '--'}
|
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">bpm</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>
|
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
|
||||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||||
<span className="text-3xl font-bold text-violet-400">
|
<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>
|
||||||
<span className="text-sm text-gray-500">ms</span>
|
<span className="text-sm text-gray-500">ms</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -567,7 +317,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<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) : '--'}
|
{day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'}
|
||||||
</span>
|
</span>
|
||||||
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max</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>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
||||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
<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) : '--'}
|
{snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'}
|
||||||
</span>
|
</span>
|
||||||
{snapshotWeight && <span className="text-xs text-gray-500">kg</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>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -688,16 +438,58 @@ const statusDot = (statusKey) => (props) => {
|
|||||||
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
|
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)
|
const vals = data.filter(d => d[dataKey] != null)
|
||||||
if (!vals.length) return (
|
if (!vals.length) return (
|
||||||
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
<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 (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
<ComposedChart
|
<ComposedChart
|
||||||
data={data}
|
data={chartData}
|
||||||
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
|
||||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||||
onClick={evt => {
|
onClick={evt => {
|
||||||
const p = evt?.activePayload?.[0]?.payload
|
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" />
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||||
tickFormatter={formatter} domain={domain} />
|
tickFormatter={formatter} domain={domain} />
|
||||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
<Tooltip content={<ChartTooltip formatter={formatter} />} />
|
||||||
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
{hasBand && (
|
||||||
|
<Area type="monotone" dataKey="__band" stroke="none" fill={bandColor} fillOpacity={0.18}
|
||||||
|
connectNulls isAnimationActive={false} legendType="none" activeDot={false} />
|
||||||
|
)}
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||||
)}
|
)}
|
||||||
{(referenceLines || []).map((rl, i) => (
|
{(referenceLines || []).map((rl, i) => (
|
||||||
<ReferenceLine key={i} {...rl} />
|
<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. */}
|
covers it wherever real data exists, leaving only gaps shown dashed. */}
|
||||||
<Line type="monotone" dataKey={dataKey} stroke={color} strokeWidth={1.5}
|
<Line type="monotone" dataKey={dataKey} stroke={color} strokeWidth={1.5}
|
||||||
strokeDasharray="4 4" dot={false} connectNulls isAnimationActive={false} legendType="none" />
|
strokeDasharray="4 4" dot={false} connectNulls isAnimationActive={false} legendType="none" />
|
||||||
|
{fillArea ? (
|
||||||
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||||
fill={`url(#grad-${dataKey})`}
|
fill={`url(#grad-${dataKey})`}
|
||||||
dot={statusDotKey ? statusDot(statusDotKey) : (showDots ? { fill: color, r: 3, strokeWidth: 0 } : false)}
|
dot={statusDotKey ? statusDot(statusDotKey) : (showDots ? { fill: color, r: 3, strokeWidth: 0 } : false)}
|
||||||
connectNulls={false} isAnimationActive={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>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)
|
)
|
||||||
@@ -753,10 +555,10 @@ function SleepChart({ data, selectedDate, onDayClick }) {
|
|||||||
.filter(t => t > 0)
|
.filter(t => t > 0)
|
||||||
const avgSleep = totals.length ? +(totals.reduce((a, b) => a + b, 0) / totals.length).toFixed(1) : null
|
const avgSleep = totals.length ? +(totals.reduce((a, b) => a + b, 0) / totals.length).toFixed(1) : null
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<ResponsiveContainer width="100%" height={170}>
|
||||||
<BarChart
|
<BarChart
|
||||||
data={chartData}
|
data={chartData}
|
||||||
margin={{ top: 4, right: 44, bottom: 4, left: 0 }}
|
margin={{ top: 4, right: 44, bottom: 0, left: 0 }}
|
||||||
barSize={6}
|
barSize={6}
|
||||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||||
onClick={evt => {
|
onClick={evt => {
|
||||||
@@ -836,8 +638,11 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
|||||||
const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
|
const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
|
||||||
const minKg = Math.min(...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 goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null
|
||||||
const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent
|
// ±5 kg around the displayed weight range; keep the goal line in view if set.
|
||||||
const yMin = Math.max(0, Math.floor(toU(Math.max(0, minKg - 20)))) // lowest weight − 20 kg equivalent
|
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`)
|
const fmtVal = (v) => (imperial ? `${fmtStLb(v)} (${Math.round(v)} lb)` : `${v.toFixed(1)} kg`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -845,8 +650,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
|
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<ResponsiveContainer width="100%" height={170}>
|
||||||
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
|
||||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||||
onClick={evt => {
|
onClick={evt => {
|
||||||
const p = evt?.activePayload?.[0]?.payload
|
const p = evt?.activePayload?.[0]?.payload
|
||||||
@@ -854,8 +659,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
|||||||
}}>
|
}}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<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}
|
<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 }} />
|
label={{ value: `Goal ${imperial ? fmtStLb(goalU) : `${goalU} kg`}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
|
||||||
)}
|
)}
|
||||||
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
|
<Area type="monotone" dataKey="w" stroke="#3b82f6" strokeWidth={2}
|
||||||
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
|
fill="url(#grad-weight)" dot={{ fill: '#3b82f6', r: 3, strokeWidth: 0 }}
|
||||||
connectNulls isAnimationActive={false} />
|
connectNulls isAnimationActive={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -922,6 +727,21 @@ export default function HealthPage() {
|
|||||||
})
|
})
|
||||||
const metrics = rawMetrics || []
|
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
|
// Snapshot navigation: newest-first sorted list of all available days
|
||||||
const allDaysSorted = useMemo(
|
const allDaysSorted = useMemo(
|
||||||
() => (allDays || []).slice().sort((a, b) => b.date.localeCompare(a.date)),
|
() => (allDays || []).slice().sort((a, b) => b.date.localeCompare(a.date)),
|
||||||
@@ -956,6 +776,12 @@ export default function HealthPage() {
|
|||||||
return found ? found.vo2max : null
|
return found ? found.vo2max : null
|
||||||
}, [allDaysSorted])
|
}, [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.
|
// Weight for the snapshot: the selected day's, or the most recent earlier reading.
|
||||||
const snapshotWeight = useMemo(() => {
|
const snapshotWeight = useMemo(() => {
|
||||||
if (!selectedDay) return null
|
if (!selectedDay) return null
|
||||||
@@ -1053,7 +879,7 @@ export default function HealthPage() {
|
|||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
|
<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)}
|
formatter={v => Math.round(v)}
|
||||||
domain={[0, 200]}
|
domain={[0, 200]}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
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="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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">
|
<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: '#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: '#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>
|
||||||
</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`}
|
formatter={v => `${Math.round(v)} ms`}
|
||||||
|
fillArea={false} domain={hrvDomain}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||||
statusDotKey="hrv_status"
|
statusDotKey="hrv_status"
|
||||||
referenceLines={[
|
bandLowKey="hrv_baseline_low" bandHighKey="hrv_baseline_upper" bandColor="#9ca3af"
|
||||||
{ 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 } },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep</h3>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<SleepChart data={metrics}
|
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||||
<div className="flex gap-4 mt-2">
|
|
||||||
{[['Deep','#6366f1'],['REM','#7c3aed'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
|
{[['Deep','#6366f1'],['REM','#7c3aed'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
|
||||||
<div key={l} className="flex items-center gap-1.5">
|
<span key={l} className="flex items-center gap-1">
|
||||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
<span className="w-2 h-2 rounded-sm" style={{ backgroundColor: c }} />{l}
|
||||||
<span className="text-xs text-gray-400">{l}</span>
|
</span>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<SleepChart data={metrics}
|
||||||
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{metrics.some(d => d.sleep_score != null) && (
|
{metrics.some(d => d.sleep_score != null) && (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
@@ -1117,10 +942,10 @@ export default function HealthPage() {
|
|||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
|
<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
|
<BarChart
|
||||||
data={metrics}
|
data={metrics}
|
||||||
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
|
||||||
barSize={6}
|
barSize={6}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={evt => {
|
onClick={evt => {
|
||||||
@@ -1144,7 +969,7 @@ export default function HealthPage() {
|
|||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
|
<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)}
|
formatter={v => Math.round(v)}
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
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">
|
<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>
|
<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)}
|
formatter={v => Math.round(v)}
|
||||||
domain={[0, 200]}
|
domain={[0, 200]}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
@@ -1172,9 +997,9 @@ export default function HealthPage() {
|
|||||||
{metrics.some(d => d.vo2max) && (
|
{metrics.some(d => d.vo2max) && (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
<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)}
|
formatter={v => v.toFixed(1)}
|
||||||
domain={[30, 70]}
|
domain={vo2Domain}
|
||||||
connectNulls showDots
|
connectNulls showDots
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { useAuthStore } from '../hooks/useAuth'
|
import { useAuthStore } from '../hooks/useAuth'
|
||||||
import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync'
|
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",
|
// Human-friendly description of the automatic sync cadence, e.g. "every 30 min",
|
||||||
// "hourly", "every 2 h". Driven by the backend's configured interval.
|
// "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' }) {
|
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<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' })
|
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
|
// PocketID config
|
||||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
|
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
|
||||||
const [pidSaved, setPidSaved] = useState(false)
|
const [pidSaved, setPidSaved] = useState(false)
|
||||||
@@ -186,6 +257,18 @@ export default function ProfilePage() {
|
|||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
|
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
|
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -243,7 +326,7 @@ export default function ProfilePage() {
|
|||||||
{healthSummary?.latest?.weight_kg && (
|
{healthSummary?.latest?.weight_kg && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Current weight (from Garmin)</p>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,6 +347,55 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</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 */}
|
{/* Password change */}
|
||||||
<Section title="Change Password">
|
<Section title="Change Password">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -445,6 +577,94 @@ export default function ProfilePage() {
|
|||||||
})()}
|
})()}
|
||||||
</Section>
|
</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 */}
|
{/* PocketID — admin only */}
|
||||||
{user?.is_admin && (
|
{user?.is_admin && (
|
||||||
<Section title="🔑 PocketID Passkey Authentication (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 { format } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
|
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
|
||||||
|
import { useUnit } from '../hooks/useUnits'
|
||||||
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
||||||
|
|
||||||
const SPORTS = ['running', 'cycling']
|
const SPORTS = ['running', 'cycling']
|
||||||
@@ -155,6 +156,7 @@ function DistancePRs() {
|
|||||||
|
|
||||||
function RouteRecords() {
|
function RouteRecords() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const unit = useUnit()
|
||||||
const { data: records, isLoading } = useQuery({
|
const { data: records, isLoading } = useQuery({
|
||||||
queryKey: ['route-records'],
|
queryKey: ['route-records'],
|
||||||
queryFn: () => api.get('/records/routes').then(r => r.data),
|
queryFn: () => api.get('/records/routes').then(r => r.data),
|
||||||
@@ -198,13 +200,13 @@ function RouteRecords() {
|
|||||||
{rec.route_name}
|
{rec.route_name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
{formatDistance(rec.distance_m)}
|
{formatDistance(rec.distance_m, unit)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||||
{formatDuration(rec.duration_s)}
|
{formatDuration(rec.duration_s)}
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden sm:table-cell 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">
|
||||||
{formatPace(rec.avg_speed_ms, rec.sport_type)}
|
{formatPace(rec.avg_speed_ms, rec.sport_type, unit)}
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden sm:table-cell 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)}
|
{formatDate(rec.start_time)}
|
||||||
@@ -243,6 +245,7 @@ function SegmentLeaderboard({ segmentId }) {
|
|||||||
|
|
||||||
function SegmentRecords() {
|
function SegmentRecords() {
|
||||||
const [open, setOpen] = useState(null)
|
const [open, setOpen] = useState(null)
|
||||||
|
const unit = useUnit()
|
||||||
const { data: segments, isLoading } = useQuery({
|
const { data: segments, isLoading } = useQuery({
|
||||||
queryKey: ['segments'],
|
queryKey: ['segments'],
|
||||||
queryFn: () => api.get('/segments/').then(r => r.data),
|
queryFn: () => api.get('/segments/').then(r => r.data),
|
||||||
@@ -287,7 +290,7 @@ function SegmentRecords() {
|
|||||||
{seg.name}
|
{seg.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
{formatDistance(seg.distance_m)}
|
{formatDistance(seg.distance_m, unit)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||||
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
||||||
|
|||||||
@@ -1,66 +1,40 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ActivityMap from '../components/activity/ActivityMap'
|
import ActivityMap from '../components/activity/ActivityMap'
|
||||||
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
import RouteTileMap from '../components/ui/RouteTileMap'
|
||||||
|
import { formatDistance, formatDuration, formatDate, formatPace, sportColor } from '../utils/format'
|
||||||
// Decode Google encoded polyline to [[lat,lng], ...]
|
import { useUnit } from '../hooks/useUnits'
|
||||||
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' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const MEDALS = ['🥇', '🥈', '🥉']
|
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 }) {
|
function RouteDetail({ selected, setSelected }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const unit = useUnit()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [merging, setMerging] = useState(false)
|
const [merging, setMerging] = useState(false)
|
||||||
const [mergeTarget, setMergeTarget] = useState('')
|
const [mergeTarget, setMergeTarget] = useState('')
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
@@ -101,6 +75,7 @@ function RouteDetail({ selected, setSelected }) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
|
navigate('/routes')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -115,7 +90,7 @@ function RouteDetail({ selected, setSelected }) {
|
|||||||
<div className="w-full sm:w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
|
<div className="w-full sm:w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
|
||||||
{selected.reference_polyline
|
{selected.reference_polyline
|
||||||
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
|
? <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>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{editingName ? (
|
{editingName ? (
|
||||||
@@ -141,7 +116,7 @@ function RouteDetail({ selected, setSelected }) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
||||||
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
|
{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 && (
|
{selected.auto_detected && (
|
||||||
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
|
<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">
|
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>
|
<option value="">Select route to merge in…</option>
|
||||||
{otherRoutes.map(r => (
|
{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>
|
</select>
|
||||||
<button
|
<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'}`}>
|
<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)}` : ''}
|
{i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''}
|
||||||
</span>
|
</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
|
{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="text-red-400 text-xs w-16 text-right">{Math.round(act.avg_heart_rate)} bpm</span>
|
||||||
: <span className="w-16" />}
|
: <span className="w-16" />}
|
||||||
@@ -232,9 +207,13 @@ function RouteDetail({ selected, setSelected }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RoutesPage() {
|
export default function RoutesPage() {
|
||||||
|
const unit = useUnit()
|
||||||
|
const { routeId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
||||||
|
const [sortBy, setSortBy] = useState('recent')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: routes } = useQuery({
|
const { data: routes } = useQuery({
|
||||||
@@ -242,8 +221,17 @@ export default function RoutesPage() {
|
|||||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by most completions first
|
// Deep-link: when arriving at /routes/:routeId, pre-select that route.
|
||||||
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
|
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({
|
const { data: recentActivities } = useQuery({
|
||||||
queryKey: ['recent-activities-for-route'],
|
queryKey: ['recent-activities-for-route'],
|
||||||
@@ -258,23 +246,64 @@ export default function RoutesPage() {
|
|||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
setNewRoute({ name: '', activity_id: '' })
|
setNewRoute({ name: '', activity_id: '' })
|
||||||
setSelected(route)
|
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 (
|
return (
|
||||||
<div className="p-4 md:p-6 space-y-6">
|
<div className="p-4 md:p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<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.
|
Routes are auto-detected when you run the same path twice. You can also create them manually.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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)}
|
<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
|
+ New route
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create route panel */}
|
{/* Create route panel */}
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
@@ -297,7 +326,7 @@ export default function RoutesPage() {
|
|||||||
<option value="">Select an activity…</option>
|
<option value="">Select an activity…</option>
|
||||||
{recentActivities?.map(a => (
|
{recentActivities?.map(a => (
|
||||||
<option key={a.id} value={a.id}>
|
<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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -317,7 +346,8 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</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 ? (
|
{routes?.length === 0 && !showCreate ? (
|
||||||
<div className="text-center py-12 text-gray-600">
|
<div className="text-center py-12 text-gray-600">
|
||||||
<p className="text-3xl mb-2">🗺️</p>
|
<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>
|
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
<div className="space-y-6">
|
||||||
{sortedRoutes.map(route => {
|
{customRoutes.length > 0 && (
|
||||||
const style = routeSportStyle(route.sport_type)
|
<div className="space-y-2">
|
||||||
const isSelected = selected?.id === route.id
|
<h2 className="text-sm font-semibold text-gray-300">Custom routes</h2>
|
||||||
return [
|
{renderGrid(customRoutes)}
|
||||||
<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>
|
</div>
|
||||||
{route.auto_detected && <span className="text-xs text-gray-600">auto</span>}
|
)}
|
||||||
</button>,
|
{autoRoutes.length > 0 && (
|
||||||
isSelected && <RouteDetail key={`detail-${route.id}`} selected={selected} setSelected={setSelected} />,
|
<div className="space-y-2">
|
||||||
]
|
<h2 className="text-sm font-semibold text-gray-300">Auto-detected routes</h2>
|
||||||
})}
|
{renderGrid(autoRoutes)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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,6 +140,166 @@ 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() {
|
export default function UploadPage() {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 space-y-6">
|
<div className="p-4 md:p-6 space-y-6">
|
||||||
@@ -169,20 +329,21 @@ export default function UploadPage() {
|
|||||||
<li>Click "Request Your Archive"</li>
|
<li>Click "Request Your Archive"</li>
|
||||||
<li>Download and upload the ZIP file below</li>
|
<li>Download and upload the ZIP file below</li>
|
||||||
</ol>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
{/* Single FIT/GPX */}
|
{/* Single FIT/GPX/TCX */}
|
||||||
<UploadZone
|
<UploadZone
|
||||||
title="Single activity"
|
title="Single activity"
|
||||||
description="Upload a .fit or .gpx file"
|
description="Upload a .fit, .gpx or .tcx file"
|
||||||
icon="🏃"
|
icon="🏃"
|
||||||
endpoint="/upload/activity"
|
endpoint="/upload/activity"
|
||||||
accept={{
|
accept={{
|
||||||
'application/octet-stream': ['.fit'],
|
'application/octet-stream': ['.fit'],
|
||||||
'application/gpx+xml': ['.gpx'],
|
'application/gpx+xml': ['.gpx'],
|
||||||
'text/xml': ['.gpx'],
|
'text/xml': ['.gpx', '.tcx'],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -195,14 +356,8 @@ export default function UploadPage() {
|
|||||||
accept={{ 'application/zip': ['.zip'] }}
|
accept={{ 'application/zip': ['.zip'] }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Strava export */}
|
{/* Strava export — preview duplicates, then import */}
|
||||||
<UploadZone
|
<StravaExportZone />
|
||||||
title="Strava bulk export"
|
|
||||||
description="Upload your Strava archive ZIP"
|
|
||||||
icon="🚴"
|
|
||||||
endpoint="/upload/strava-export"
|
|
||||||
accept={{ 'application/zip': ['.zip'] }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Ongoing FIT files */}
|
{/* Ongoing FIT files */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
|
|||||||
@@ -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) {
|
export function formatDuration(seconds) {
|
||||||
if (!seconds) return '--'
|
if (!seconds) return '--'
|
||||||
const h = Math.floor(seconds / 3600)
|
const h = Math.floor(seconds / 3600)
|
||||||
@@ -7,28 +15,48 @@ export function formatDuration(seconds) {
|
|||||||
return `${m}:${String(s).padStart(2, '0')}`
|
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 (!speedMs || speedMs <= 0) return '--'
|
||||||
if (sportType === 'cycling') {
|
if (sportType === 'cycling') {
|
||||||
|
if (unit === 'mi') return `${(speedMs * MS_TO_MPH).toFixed(1)} mph`
|
||||||
return `${(speedMs * 3.6).toFixed(1)} km/h`
|
return `${(speedMs * 3.6).toFixed(1)} km/h`
|
||||||
}
|
}
|
||||||
const secsPerKm = 1000 / speedMs
|
const distPerUnit = unit === 'mi' ? M_PER_MI : 1000
|
||||||
const mins = Math.floor(secsPerKm / 60)
|
const secsPerUnit = distPerUnit / speedMs
|
||||||
const secs = Math.floor(secsPerKm % 60)
|
const mins = Math.floor(secsPerUnit / 60)
|
||||||
return `${mins}:${String(secs).padStart(2, '0')} /km`
|
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 (!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`
|
if (metres >= 1000) return `${(metres / 1000).toFixed(2)} km`
|
||||||
return `${Math.round(metres)} m`
|
return `${Math.round(metres)} m`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatElevation(metres) {
|
export function formatElevation(metres, unit = 'km') {
|
||||||
if (metres == null) return '--'
|
if (metres == null) return '--'
|
||||||
|
if (unit === 'mi') return `${Math.round(metres * FT_PER_M)} ft`
|
||||||
return `${Math.round(metres)} m`
|
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) {
|
export function formatHeartRate(bpm) {
|
||||||
if (!bpm) return '--'
|
if (!bpm) return '--'
|
||||||
return `${Math.round(bpm)} bpm`
|
return `${Math.round(bpm)} bpm`
|
||||||
@@ -77,18 +105,35 @@ export function hrZoneColor(zone) {
|
|||||||
return colors[zone] || '#9ca3af'
|
return colors[zone] || '#9ca3af'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sportIcon(sportType) {
|
// Standard metric colours, used everywhere a metric is charted or shown as a stat
|
||||||
const icons = {
|
// so the whole platform stays consistent. (Sleep keeps its per-stage graph colours;
|
||||||
running: '🏃', cycling: '🚴', hiking: '🥾',
|
// SLEEP here is the light-sleep violet used for sleep *stat* text. VO2 max is the
|
||||||
walking: '🚶', other: '⚡',
|
// only metric coloured dynamically — by its gauge category — so it lives elsewhere.)
|
||||||
}
|
export const METRIC_COLOR = {
|
||||||
return icons[sportType?.toLowerCase()] || '⚡'
|
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) {
|
export function sportColor(sportType) {
|
||||||
|
const s = sportType?.toLowerCase()
|
||||||
const colors = {
|
const colors = {
|
||||||
running: '#3b82f6', cycling: '#f97316',
|
running: '#22c55e', // green
|
||||||
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
|
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
|
||||||
|
}
|
||||||
@@ -216,6 +216,19 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
|
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
|
||||||
app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
|
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
|
continue
|
||||||
|
|
||||||
# Slow-path dedup: activity imported via bulk export (no garmin_activity_id).
|
# 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.
|
# Match on the actual start instant (not just the date — two activities on
|
||||||
act_start_str = act.get("startTimeLocal") or act.get("startTimeGMT") or ""
|
# 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:
|
if act_start_str:
|
||||||
try:
|
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"))
|
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(
|
time_match = db.execute(
|
||||||
select(Activity).where(
|
select(Activity).where(
|
||||||
Activity.user_id == user_id,
|
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 time_match:
|
||||||
if not time_match.garmin_activity_id:
|
if not time_match.garmin_activity_id:
|
||||||
time_match.garmin_activity_id = garmin_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
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
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)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
|
|||||||
@@ -13,18 +13,16 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.23.1",
|
"react-router-dom": "^6.23.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react-leaflet": "^4.2.1",
|
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"zustand": "^4.5.2",
|
"zustand": "^4.5.2",
|
||||||
"@tanstack/react-query": "^5.40.0",
|
"@tanstack/react-query": "^5.40.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"react-dropzone": "^14.2.3"
|
"react-dropzone": "^14.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"vite": "^5.2.13",
|
"vite": "^8.1.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.4"
|
"tailwindcss": "^3.4.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user