Compare commits

..
35 Commits
Author SHA1 Message Date
owain 92cea4dc99 fix: route leaderboards rank by moving time not elapsed — top-10 route times and route completions list now use COALESCE(moving_time_s, duration_s) so mid-activity pauses don't inflate times (consistent with moving-time avg pace)
Build and push images / validate (push) Successful in 6s
Build and push images / build-backend (push) Successful in 1m11s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 6s
2026-07-01 21:04:46 +01:00
owainandClaude Fable 5 acf553ff5c docs: correct CLAUDE.md drift — beat also schedules sync_all_strava; three nginx configs (dev nginx/, deploy root, in-image SPA) with prod stack using Traefik labels; persist_activity lives in workers/tasks.py; add missing Celery tasks and ui components
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:01:42 +01:00
owain 4d5938cf33 fix(deps): resolve npm audit vulns — form-data 4.0.6 (high CRLF), Vite 5→8 + plugin-react 6 (esbuild dev-server moderate); bump frontend build image to node:22-alpine; mirror to export snapshot
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 7s
2026-06-25 12:31:17 +01:00
owain 64caaad4e7 fix: cleanup StravaConfig on user delete (FK violation); equalize login timing (user enum); scope upload task-status to owner; SQL-side data-point downsampling; drop unused clsx/react-leaflet deps
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 19s
2026-06-25 12:21:25 +01:00
owain 84eb1c46bd fix: map settings no longer revert/lose in-flight edits when profile refetches (dirty-flag guards hydrate clobber)
Build and push images / validate (push) Successful in 4s
Build and push images / build-backend (push) Successful in 54s
Build and push images / build-worker (push) Successful in 44s
Build and push images / build-frontend (push) Successful in 10s
2026-06-25 11:04:53 +01:00
owain 3c97595093 feat: persist map tile settings (provider/style/API keys) server-side on user record; unbake Thunderforest default key into backend config (THUNDERFOREST_DEFAULT_KEY) served via profile
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s
2026-06-23 12:37:27 +01:00
owain ffe285b7ba feat: global map provider/tiles setting (Thunderforest, MapTiler, CARTO, OSM, Esri) selectable in Profile with style + API key + live preview; all maps use it via useMapSettings/resolveTile
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-23 12:29:50 +01:00
owain deecf668d2 feat: route tiles use Thunderforest Outdoors map style (dense labels, paths, contours)
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-23 12:17:13 +01:00
owain 0901ed0ef3 feat: route tiles use OpenStreetMap standard raster for denser place/street labels
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-23 12:11:59 +01:00
owain 62cea59ef8 feat: taller route tiles (h-46), labels overlay above route line for more place names, renamed auto routes become custom (rename clears auto_detected + startup backfill)
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-23 12:08:33 +01:00
owain 16dff88564 fix: route tiles fill with fractional zoom (zoomSnap 0) so the route isn't tiny with wide margins; taller tiles (h-36)
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-23 12:02:49 +01:00
owain 73bc986219 feat: routes page — deep-link to specific route, sport-colour tiles over real map, sort filter (distance/completions/last-completed), group custom vs auto-detected, taller map tiles
Build and push images / validate (push) Successful in 4s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 8s
2026-06-23 11:57:39 +01:00
owain 4272c5dc4c feat: rename segments + create routes from an activity (detail page & dashboard recent activities)
Build and push images / validate (push) Successful in 9s
Build and push images / build-backend (push) Successful in 1m15s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 10s
2026-06-23 11:45:19 +01:00
owain 156ce60aa0 fix: personal records only count watch-recorded FIT activities; exclude phone/Strava GPX/TCX imports (bad GPS teleporting in old activities)
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 5s
2026-06-21 19:51:32 +01:00
owain e2870e86a8 feat: Activities filters (type/year/date-range/distance) + Summary page with all-time & per-year/per-sport totals and distance-per-year chart
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-21 16:29:33 +01:00
owain 69ecdaa4b2 feat: Strava export dry-run preview (new vs duplicate counts + confirm import) and prefer existing Garmin data over Strava on dedup
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-21 10:41:54 +01:00
owain 07139120df feat: Strava support — bulk-export .gz/.tcx import fix + .tcx parser; full Strava API OAuth live sync (activities via streams) with Profile connect UI; colour gym/no-GPS sports red
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 8s
2026-06-21 10:18:09 +01:00
owain e8615dd12d frontend: week-filter totals (distance/time/count) on Activities; colour HIIT & gym/no-GPS workouts red
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-21 09:40:50 +01:00
owain 7d52b28f9d frontend: share one BodyBatteryChart component across Health + Dashboard (Dashboard now shows mid-activity pause markers too)
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-20 20:16:06 +01:00
owain e33946e270 feat: avg pace over moving time (fixes elapsed-based pace on paused activities); show mid-activity pauses on Body Battery band via active_spans
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
2026-06-20 20:04:53 +01:00
owainandClaude Opus 4.8 0e1d35364f frontend: standardise metric colours + crisp activity icons platform-wide
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 8s
Colours (Health, Dashboard, Activities, ActivityDetail, Profile):
- Heart rate (resting/avg/24h/intraday + activity HR metric) → red
- Sleep stat text → light-sleep violet; unify dashboard sleep-stage palette
  to the Health-page hypnogram colours
- VO2 max graph + stat coloured by its gauge rating category (shared utils/vo2.js)
- Stress → orange; Steps stay yellow; Weight → blue
- Running → green, Cycling → orange (sportColor)

Icons: new stroke-based SVG SportIcon component replaces emoji everywhere
(lists, filters, activity detail, body-battery overlays, YTD stats) — crisp
at any resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:31:23 +01:00
owainandClaude Opus 4.8 a0912fd09a frontend: dashboard recent-activities widget no longer scrolls
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
Rows now flex to fill the card height so the list always fits exactly,
removing the vertical scrollbar that appeared once rows carried a route
line, and the spurious horizontal scrollbar caused by overflow-auto +
the -mx-2 hover bleed. Empty state preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:00:42 +01:00
owainandClaude Opus 4.8 c14f3d60fa frontend: health trend graphs — taller widgets, top-right keys, tighter x-axis spacing, HRV line-only (no fill) with baseline-based Y range, weight/VO2 Y axes scaled to displayed data
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
- All trend chart widgets taller (140->170) with bottom margin trimmed to 0 to cut empty space below the x-axis
- Sleep trend legend moved to top-right beside the title (matches HRV)
- HRV (overnight avg) now line+dots only (no purple gradient fill) so the grey baseline band reads clearly; Y axis spans baseline-low -15ms to baseline-high +15ms
- Weight Y axis now +/-5kg around displayed range (still keeps goal line in view)
- VO2 Max Y axis now displayed min/max +/-5 points instead of fixed 30-70

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:47:24 +01:00
owainandClaude Opus 4.8 227dadaca0 feat: rename activities; keep original Garmin title as a tag
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
Add Activity.original_name (migrated via init_db ALTER). The rename
endpoint stores the import title the first time a user renames and
clears it if renamed back. Activity detail page gets inline rename
(pencil) UI and shows the original title beneath the new name; the
activities list shows it as a small tag too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:35:09 +01:00
owain cc43d0f726 frontend: fix health body-battery activity bands not rendering (inline ReferenceArea so recharts recognizes them)
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
2026-06-18 14:08:44 +01:00
owain 471e43466c frontend: body battery activity overlays on dashboard; redesign highlight as coloured band below the data
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-18 13:56:16 +01:00
owain a50d13179c frontend: global km/mi distance-unit toggle across dashboard, activities, routes, records
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-18 13:01:25 +01:00
owain e61c77842f api: send Cache-Control no-store on /api responses so browsers can't cache stale auth state (e.g. garmin connected)
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 5s
2026-06-18 12:07:13 +01:00
owain 385443522b frontend nginx: no-cache on index.html so deploys aren't masked by stale SPA shell (Edge heuristic caching)
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
2026-06-18 12:00:58 +01:00
owain 66076f2585 garmin sync: fix same-day second activity skipped by date-only dedup
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 5s
2026-06-18 11:38:10 +01:00
owainandClaude Opus 4.8 3a07655e2e dashboard sleep widget: fill empty space with hypnogram graph from Health tab
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:54:49 +01:00
owain c172bbe393 lap awards: 🥇 PR rows highlighted, 🏆 route lap-best, no awards on partial laps
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
2026-06-16 12:38:29 +01:00
owain a168f594a7 fix lap-bests distance matching + show PRs on activity page
Build and push images / validate (push) Successful in 5s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s
2026-06-16 11:38:12 +01:00
owain 16144d60b4 HRV: plot Garmin overnight avg (weeklyAvg) not last-night; dedupe chart tooltips
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 8s
2026-06-16 01:01:33 +01:00
owain d01f66223b HRV baseline band from Garmin + dashboard HRV colours + route name on recent activities + sync-now race fix
Build and push images / validate (push) Successful in 7s
Build and push images / build-backend (push) Successful in 1m9s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
2026-06-16 00:13:16 +01:00
57 changed files with 4531 additions and 2082 deletions
+45 -11
View File
@@ -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
View File
@@ -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)
+4 -1
View File
@@ -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(
+3
View File
@@ -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]
+32 -2
View File
@@ -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 ────────────────────────────────────────────────────────
+23 -8
View File
@@ -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,
+27
View File
@@ -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,
+271
View File
@@ -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
View File
@@ -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 {
+7 -1
View File
@@ -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()
+9
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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"])
+46 -1
View File
@@ -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)
+180 -1
View File
@@ -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 {}
+16 -5
View File
@@ -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
+290
View File
@@ -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
View File
@@ -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(
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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 ./
+8
View File
@@ -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";
+545 -1150
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -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"
} }
} }
+3
View File
@@ -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
+56 -10
View File
@@ -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>
)
}
+15
View File
@@ -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>
}
+15
View File
@@ -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>
)
}
+70
View File
@@ -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>
)
}
+6 -2
View File
@@ -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>
) )
+24
View File
@@ -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>
)
}
+139
View File
@@ -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])
}
+30 -5
View File
@@ -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()
+25
View File
@@ -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)
+121 -56
View File
@@ -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
+148 -22
View File
@@ -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 && (
+105 -97
View File
@@ -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
View File
@@ -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>
+221 -1
View File
@@ -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 wont 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 arent 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 sites 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)">
+6 -3
View File
@@ -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) : '--'}
+107 -87
View File
@@ -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>
+124
View File
@@ -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>
)
}
+166 -11
View File
@@ -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">Well scan it and show whats 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">
+61 -16
View File
@@ -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'
} }
+121
View File
@@ -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 || {})
}
+53
View File
@@ -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
}
+13
View File
@@ -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 -1
View File
@@ -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 ./
+8
View File
@@ -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";
+2 -4
View File
@@ -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"