Compare commits

...

12 Commits

Author SHA1 Message Date
owain ec87f68729 Add trend-range gating, vehicle filter, sync cancel, moving time, and UI fixes
Build and push images / validate (push) Successful in 9s
Build and push images / build-backend (push) Successful in 1m57s
Build and push images / build-worker (push) Successful in 50s
Build and push images / build-frontend (push) Successful in 24s
- Grey out trend ranges beyond available health history
- Reject implausibly fast (vehicle) activities on upload with feedback
- Add cancel button + cooperative cancellation for Garmin sync
- Show daily steps prominently on the dashboard
- Clear errors for malformed/empty upload ZIPs
- Snap-target dot when drawing a segment on the map
- Time-axis fallback for stationary/HIIT HR timelines; hide map when no GPS
- Parse and display moving time (timer) vs elapsed; backfill task
- Restyle SegmentsPanel like RouteLeaderboard; Laps/Routes/Segments on one row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:41:56 +01:00
owain 057eb9391a Fix passkey-disabled message obscured by null hash check
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 5s
Build and push images / build-frontend (push) Successful in 5s
Check pocketid_sub before hashed_password so users with a linked
passkey (and hence a null hash) get the helpful message rather than
"Invalid credentials".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:38:12 +01:00
owain 01a8fe135c Disable password login once a passkey is linked
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 6s
- /token: reject password auth with a clear message if pocketid_sub is
  set on the account — passkey-linked users must sign in via PocketID
- Link callback + auto-link-by-email: null out hashed_password when the
  passkey is attached so the old hash can't be used even if the check
  above were bypassed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:34:23 +01:00
owain d350e9caea Add per-route top-10 leaderboard to activity detail
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 4s
Build and push images / build-frontend (push) Successful in 9s
New /activities/{id}/route-leaderboard endpoint ranks the user's timed
efforts on the same route; frontend RouteLeaderboard card sits beside
Laps, showing this activity's time/rank/gap and the top 10 (current
effort highlighted green, also surfaced if outside the top 10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:37:37 +01:00
owain bdd5f80c7e Harden auth/upload, fix PR-delete cascade and sync backfill
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
- OIDC: require signed short-lived state on login callback; reject
  missing userinfo sub (account-takeover guard); validate token
  exchange + userinfo responses
- Upload: safe zip extraction (path-traversal + zip-bomb cap),
  streamed size-capped writes, sanitised filenames
- Garmin: increasing lookback resets last_sync_at for one-time backfill
- Activities: delete/reprocess remove PersonalRecord rows (no FK cascade)
- Profile: validate /weight limit; sync lookback UI copy
- Dashboard: sleep shading uses same day as charted body battery

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:24:24 +01:00
owain 04689a29bd Cut Garmin sync API volume; dashboard/health/records/UI improvements
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 9s
Garmin Connect sync:
- Incremental syncs now re-fetch only a 1-day buffer (yesterday + today)
  instead of the full lookback window every run. Full lookback applies on
  the first sync only. Cuts steady-state API calls ~10x.
- Beat interval is now configurable via GARMIN_SYNC_INTERVAL_MINUTES and
  surfaced to the UI; the sync toggle is relabelled to the real cadence.

Frontend:
- Collapsible sidebar; clearer logged-in user + role display.
- Unified Body Battery colouring between dashboard and health (shared util).
- Sleep score trend chart on health page.
- Segments + medals on the dashboard's most-recent activity.
- Segments tab on the Records page.

Repo hygiene: add .gitignore, untrack committed __pycache__/*.pyc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:52:52 +01:00
owain 6a1726e0c3 Fix sleep score parsing, dashboard body battery, segment direction
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 9s
- Garmin sync: read sleepScores from dailySleepDTO (Garmin nests it there),
  so sleep score is actually stored instead of always null
- Dashboard: pass YYYY-MM-DD to the intraday endpoint (was a full ISO
  timestamp), so the body-battery tile populates
- Segment matching: follow the segment in its created direction with a
  path-length sanity check, so out-and-back routes no longer match an early
  start pass to a late finish (the >1h bogus segment times)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:58:53 +01:00
owain 0aa27713ca Fix follow-ups: lap bests, segments, charts, dashboard health
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
- Lap bests: compare against OTHER activities on the route (exclude self),
  so single-activity routes no longer show every lap as "best"
- Segment create: POST to trailing-slash URL (was a 307 that dropped the body);
  surface errors in the UI
- PR splits: scale GPS distance stream to the activity's official distance so
  over-measured GPS no longer yields bogus split PRs
- Speed route colours: red->orange->green->blue->purple (slow->fast) with smooth
  interpolation + a Slow/Fast gradient key under the map
- Health body battery: snap activity highlight to the categorical axis;
  white tooltip text + % suffix
- Health weight: y-min = lowest weight - 20kg; st/lb hover shows total lbs too
- Health sleep: move 8h/avg reference labels into the right margin
- Dashboard: Health-today pulls latest non-null values (sleep score, VO2 max);
  body battery tile renders a condensed colour-graded intraday graph

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:39:26 +01:00
owain bc437cce92 Batch 1: dashboard, maps, segments rewrite, health, sync UX
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 6s
Build and push images / build-frontend (push) Successful in 9s
Fixes:
- Dashboard: featured most-recent activity card with map + stats
- Maps default to Street; preferCanvas + larger tile buffer for smoother pan/zoom
- Running cadence as colour-banded dots + 165 spm guide line
- Routes: inline row expansion, rename (PATCH /routes/{id}), podium + deltas, tiled map
- Records: remove reversed pace Y-axis
- Profile: remove resting HR; add goal weight
- Health: snapshot weight carry-forward; VO2 trend axis 30-70;
  weight goal line + kg/st-lb toggle + axis max; sleep 8h/avg lines
- Garmin sync progress moved to global store with persistent floating bar

Features:
- Speed-coloured activity route (default) with Speed/Solid toggle
- GPS-geometry segments: draw on map, match across all activities,
  1st/2nd/3rd leaderboard + podium badges (replaces old distance segments)
- Lap bests: best time per lap across a route + delta column
- Body Battery: highlight activity time windows

Schema: users.goal_weight_kg ALTER; new segments/segment_efforts tables.
Removes RouteSegment, the Segments page, and segment-bests endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:59:06 +01:00
owain e5feeb1178 Add explicit "link passkey to my account" flow
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 30s
Build and push images / build-worker (push) Successful in 30s
Build and push images / build-frontend (push) Successful in 34s
Signing in by passkey on a fresh install created a new empty account because
the seeded admin has no email to match on. Add canonical SSO-style linking: an
authenticated user starts an OIDC flow whose `state` is a signed, short-lived
"link to user N" token (purpose=pocketid-link). The callback detects that state
and attaches the returned identity to that account instead of creating/matching
one — no reliance on emails lining up, and no group gating (the initiator is
already authorised; this is identity linking, not access control).

- auth.py: _make_link_state/_decode_link_state, GET /pocketid/link-url, callback
  handles state (rejects if the passkey is already on another account →
  auth_error=passkey_in_use). Expose has_passkey on /auth/me.
- Profile: "Passkey sign-in" section for all users — shows linked state or a
  "Link a passkey to this account" button; success banner on return.
- Login: messages for passkey_in_use / link_failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:11:30 +01:00
owain e0ddc4cbf4 Fix PocketID config lookup when multiple admins exist
Build and push images / validate (push) Successful in 19s
Build and push images / build-backend (push) Successful in 32s
Build and push images / build-worker (push) Successful in 30s
Build and push images / build-frontend (push) Successful in 30s
_get_pocketid_config / _get_allowed_group selected an admin row with an
unordered LIMIT 1. With more than one admin (e.g. the seeded password admin
plus a passkey-linked admin), this non-deterministically returned an admin
without PocketID config — making the passkey button disappear (available=false)
and group gating inconsistent. Add _config_admin() which prefers the admin that
actually has an issuer set, then falls back to the lowest-id admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:37:19 +01:00
owain 0e18ef2291 Fix PocketID secret wiped on re-save; log token-exchange failures
Build and push images / validate (push) Successful in 19s
Build and push images / build-backend (push) Successful in 29s
Build and push images / build-worker (push) Successful in 29s
Build and push images / build-frontend (push) Successful in 29s
save_pocketid_config cleared the stored client secret whenever the form was
submitted with a blank secret field — but the UI hint says blank means "keep
existing". Re-saving config (e.g. to set the allowed group) therefore wiped the
secret and broke token exchange ("Token exchange failed"). Now a blank field
keeps the existing secret; only a non-empty value overwrites it.

Also log PocketID's actual token-endpoint response body on failure so the cause
(invalid_client, redirect_uri mismatch, etc.) is visible in backend logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:26:35 +01:00
50 changed files with 6471 additions and 1735 deletions
+14
View File
@@ -0,0 +1,14 @@
# Python
__pycache__/
*.py[cod]
# Node / frontend build artifacts
node_modules/
dist/
# Environment / secrets
.env
.env.*
# OS noise
.DS_Store
+31 -7
View File
@@ -31,6 +31,21 @@ The app is served on port 80 by nginx, which proxies `/api/*` to the backend (po
There are no automated tests. Verification is done by running the app and observing behaviour. There are no automated tests. Verification is done by running the app and observing behaviour.
## Debugging running containers
The production stack runs in `~/milevault_docker` with fixed container names. Use these to investigate issues — never patch the running files:
```bash
# Tail logs from a specific container
docker logs -f milevault_backend
docker logs -f milevault_worker
docker logs -f milevault_db
# Run a one-off query or command inside a container
docker exec milevault_backend python -c "from app.core.config import settings; print(settings.base_url)"
docker exec -it milevault_db psql -U milevault -d milevault
```
## Building and deploying ## Building and deploying
`docker-compose.yml` — build from source (dev/CI). `docker-compose.yml` — build from source (dev/CI).
@@ -38,6 +53,12 @@ There are no automated tests. Verification is done by running the app and observ
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`.
`./deploy.sh "<commit message>"` is the normal dev loop here: it commits everything, pushes to `main` (triggering the image build), and stops the running stack in `../milevault_docker`. After the build finishes, run `docker compose pull && docker compose up -d` there. This matches the repo rule: fix files in `~/milevault`, push to git — never patch the running containers in `~/milevault_docker`.
**CI validation**: The build workflow runs a `validate` job before building images. It will fail if `@polyline-codec` appears in `frontend/package.json` or if `npm ci` is used in `frontend/Dockerfile` (no lockfile exists — always use `npm install`). Fix these before pushing.
**`VITE_MAPBOX_TOKEN`** is baked empty by the CI build (`build-args: VITE_MAPBOX_TOKEN=`), so satellite tiles are disabled in all pre-built images. To enable them, rebuild locally with the token set in `.env`.
```bash ```bash
# Rebuild and restart from source: # Rebuild and restart from source:
docker compose build --no-cache docker compose build --no-cache
@@ -66,13 +87,13 @@ 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` - `api/` — routers: `auth`, `activities`, `routes`, `health`, `records`, `upload`, `profile`, `garmin_sync`, `users`, `segments`
- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `RouteSegment`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig` - `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `Segment`, `SegmentEffort`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig` (the old `RouteSegment` model was removed in the segments rewrite; a new GPS-geometry `Segment`/`SegmentEffort` pair replaces it)
- `services/fit_parser.py` — parses Garmin FIT and GPX files; handles raw FIT timestamps (FIT epoch offset 631065600s) and semicircle→degree conversion - `services/fit_parser.py` — parses Garmin FIT and GPX 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`, `process_garmin_health_zip`, `sync_all_garmin_connect` (beat-scheduled) - `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`
### Key design decisions ### Key design decisions
@@ -91,9 +112,9 @@ docker compose -f docker-compose.deploy.yml up -d
- `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 state
- `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc. - `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc.
- `pages/` — one file per route; includes `SegmentsPage` for route segment management - `pages/` — one file per route: `Dashboard`, `Activities`, `ActivityDetail`, `Routes`, `Records`, `Health`, `Upload`, `Profile`, `Users`, `Login`
- `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable` - `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route)
- `components/ui/RouteMiniMap` small Leaflet map used in route/segment cards - `components/ui/``Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards)
The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time. The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time.
@@ -113,8 +134,11 @@ Required in `.env` (or passed to Docker Compose):
| `HTTP_PORT` | Host port for nginx (default: `80`) | | `HTTP_PORT` | Host port for nginx (default: `80`) |
| `FILE_STORE_PATH` | Where uploaded FIT files are stored (default: `/data/files`) | | `FILE_STORE_PATH` | Where uploaded FIT files are stored (default: `/data/files`) |
| `BASE_URL` | Used for PocketID OAuth callback redirect URI | | `BASE_URL` | Used for PocketID OAuth callback redirect URI |
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer | | `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) |
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer (baked at build time) |
| `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect (default: `30`) |
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC | | `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |
## milevault_export/ ## milevault_export/
Binary file not shown.
Binary file not shown.
Binary file not shown.
+105 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime
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.models.user import User, Activity, ActivityDataPoint, ActivityLap from app.models.user import User, Activity, ActivityDataPoint, ActivityLap, PersonalRecord
router = APIRouter() router = APIRouter()
@@ -35,6 +35,7 @@ class ActivitySummary(BaseModel):
class ActivityDetail(ActivitySummary): class ActivityDetail(ActivitySummary):
end_time: Optional[datetime] end_time: Optional[datetime]
moving_time_s: Optional[float]
elevation_loss_m: Optional[float] elevation_loss_m: Optional[float]
max_heart_rate: Optional[float] max_heart_rate: Optional[float]
avg_power: Optional[float] avg_power: Optional[float]
@@ -195,6 +196,103 @@ async def get_laps(
return result.scalars().all() return result.scalars().all()
@router.get("/{activity_id}/lap-bests")
async def get_lap_bests(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Best (fastest) time per lap number across all activities on the same route."""
act = (await db.execute(
select(Activity).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")
if not act.named_route_id:
return {}
# Best per lap number across OTHER activities on the same route, so the
# comparison is meaningful (excluding this activity from its own benchmark).
rows = (await db.execute(
select(ActivityLap.lap_number, func.min(ActivityLap.duration_s))
.join(Activity, Activity.id == ActivityLap.activity_id)
.where(
Activity.named_route_id == act.named_route_id,
Activity.user_id == current_user.id,
Activity.id != activity_id,
ActivityLap.duration_s.isnot(None),
)
.group_by(ActivityLap.lap_number)
)).all()
return {str(lap_number): best for lap_number, best in rows}
@router.get("/{activity_id}/route-leaderboard")
async def get_route_leaderboard(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fastest-time leaderboard across all of this user's activities on the same
route. Returns this activity's rank/gap plus the top 10. Null if the activity
has no associated route (or no timed efforts to rank)."""
act = (await db.execute(
select(Activity).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")
if not act.named_route_id:
return None
rows = (await db.execute(
select(
Activity.id, Activity.name, Activity.start_time,
Activity.duration_s, Activity.distance_m, Activity.avg_heart_rate,
)
.where(
Activity.named_route_id == act.named_route_id,
Activity.user_id == current_user.id,
Activity.duration_s.isnot(None),
)
.order_by(Activity.duration_s)
)).all()
if not rows:
return None
fastest_s = rows[0].duration_s
entries = []
current = None
for i, r in enumerate(rows):
entry = {
"rank": i + 1,
"activity_id": r.id,
"name": r.name,
"start_time": r.start_time,
"duration_s": r.duration_s,
"distance_m": r.distance_m,
"avg_heart_rate": r.avg_heart_rate,
"gap_s": r.duration_s - fastest_s,
"is_current": r.id == activity_id,
}
if entry["is_current"]:
current = entry
entries.append(entry)
return {
"route_id": act.named_route_id,
"total": len(entries),
"fastest_s": fastest_s,
"current": current,
"top": entries[:10],
}
@router.patch("/{activity_id}/name") @router.patch("/{activity_id}/name")
async def rename_activity( async def rename_activity(
activity_id: int, activity_id: int,
@@ -232,6 +330,10 @@ async def delete_activity(
activity = result.scalar_one_or_none() activity = result.scalar_one_or_none()
if not activity: if not activity:
raise HTTPException(status_code=404, detail="Activity not found") raise HTTPException(status_code=404, detail="Activity not found")
# PersonalRecord.activity_id has no cascade, so remove the activity's PR rows
# first or the delete fails the FK constraint. (segment_efforts cascade in DB;
# data_points/laps cascade via the ORM relationship.)
await db.execute(delete(PersonalRecord).where(PersonalRecord.activity_id == activity_id))
await db.delete(activity) await db.delete(activity)
await db.commit() await db.commit()
@@ -263,6 +365,8 @@ async def reprocess_activity(
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id)) await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id)) await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id))
# Drop PR rows referencing this activity (no cascade); the re-parse re-computes them.
await db.execute(delete(PersonalRecord).where(PersonalRecord.activity_id == activity_id))
await db.delete(activity) await db.delete(activity)
await db.commit() await db.commit()
+158 -9
View File
@@ -4,6 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import timedelta
from jose import jwt, JWTError
import httpx import httpx
from app.core.database import get_db from app.core.database import get_db
@@ -13,11 +15,79 @@ from app.models.user import User
router = APIRouter() router = APIRouter()
# Marks a short-lived OIDC `state` token as an account-link request (as opposed
# to a normal sign-in), so the callback attaches the passkey to a known user
# instead of creating/looking-up by identity.
LINK_STATE_PURPOSE = "pocketid-link"
LOGIN_STATE_PURPOSE = "pocketid-login"
def _make_link_state(user_id: int) -> str:
"""Signed, short-lived token carrying 'link this passkey to user_id' intent."""
return create_access_token(
{"sub": str(user_id), "purpose": LINK_STATE_PURPOSE},
expires_delta=timedelta(minutes=10),
)
def _make_login_state() -> str:
"""Signed, short-lived CSRF token proving the login flow started from this app."""
return create_access_token(
{"sub": "login", "purpose": LOGIN_STATE_PURPOSE},
expires_delta=timedelta(minutes=10),
)
def _valid_login_state(state: Optional[str]) -> bool:
"""True if `state` is a valid, unexpired login-state token we issued."""
if not state:
return False
try:
payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm])
return payload.get("purpose") == LOGIN_STATE_PURPOSE
except JWTError:
return False
def _decode_link_state(state: Optional[str]) -> Optional[int]:
"""Return the user id from a valid link-state token, else None."""
if not state:
return None
try:
payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("purpose") != LINK_STATE_PURPOSE:
return None
return int(payload["sub"])
except (JWTError, KeyError, TypeError, ValueError):
return None
async def _config_admin(db: AsyncSession):
"""The admin row that holds instance-wide PocketID settings.
Settings live on an admin user row, but there can be more than one admin.
Prefer the admin that actually has an issuer configured; otherwise fall back
to the lowest-id admin. Without this, an unordered LIMIT 1 could return an
admin with no config and make PocketID look disabled / gating inconsistent.
"""
result = await db.execute(
select(User)
.where(User.is_admin == True, User.pocketid_issuer.isnot(None))
.order_by(User.id)
.limit(1)
)
admin = result.scalar_one_or_none()
if admin is None:
result = await db.execute(
select(User).where(User.is_admin == True).order_by(User.id).limit(1)
)
admin = result.scalar_one_or_none()
return admin
async def _get_pocketid_config(db: AsyncSession): async def _get_pocketid_config(db: AsyncSession):
"""Get PocketID config from DB (admin user) falling back to env vars.""" """Get PocketID config from DB (admin user) falling back to env vars."""
result = await db.execute(select(User).where(User.is_admin == True).limit(1)) admin = await _config_admin(db)
admin = result.scalar_one_or_none()
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id
client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret
@@ -26,8 +96,7 @@ async def _get_pocketid_config(db: AsyncSession):
async def _get_allowed_group(db: AsyncSession): async def _get_allowed_group(db: AsyncSession):
"""Group a PocketID user must belong to in order to sign in (None = allow all).""" """Group a PocketID user must belong to in order to sign in (None = allow all)."""
result = await db.execute(select(User).where(User.is_admin == True).limit(1)) admin = await _config_admin(db)
admin = result.scalar_one_or_none()
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
return (group or "").strip() or None return (group or "").strip() or None
@@ -58,6 +127,7 @@ class UserOut(BaseModel):
username: str username: str
email: Optional[str] email: Optional[str]
is_admin: bool is_admin: bool
has_passkey: bool = False
class Config: class Config:
from_attributes = True from_attributes = True
@@ -70,9 +140,14 @@ 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 or not user.hashed_password: if not user:
raise HTTPException(status_code=400, detail="Invalid credentials") raise HTTPException(status_code=400, detail="Invalid credentials")
if not verify_password(form_data.password, user.hashed_password): if user.pocketid_sub is not None:
raise HTTPException(
status_code=400,
detail="Password login is disabled for this account — use your passkey to sign in.",
)
if not user.hashed_password or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Invalid credentials") raise HTTPException(status_code=400, detail="Invalid credentials")
token = create_access_token({"sub": str(user.id)}) token = create_access_token({"sub": str(user.id)})
return Token(access_token=token, token_type="bearer", return Token(access_token=token, token_type="bearer",
@@ -81,7 +156,13 @@ async def login(
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)): async def get_me(current_user: User = Depends(get_current_user)):
return current_user return UserOut(
id=current_user.id,
username=current_user.username,
email=current_user.email,
is_admin=current_user.is_admin,
has_passkey=current_user.pocketid_sub is not None,
)
@router.get("/pocketid/available") @router.get("/pocketid/available")
@@ -101,12 +182,37 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback", "redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"response_type": "code", "response_type": "code",
"scope": "openid profile email groups", "scope": "openid profile email groups",
"state": _make_login_state(),
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/link-url")
async def pocketid_link_url(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Authenticated user starts an OIDC flow to attach a passkey to THEIR account.
The `state` carries a signed 'link to this user' token so the callback links
the returned identity instead of creating/matching a new account.
"""
issuer, client_id, _ = await _get_pocketid_config(db)
if not issuer or not client_id:
raise HTTPException(status_code=404, detail="PocketID not configured")
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email groups",
"state": _make_link_state(current_user.id),
} }
return {"url": f"{issuer}/authorize?{urlencode(params)}"} return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/callback") @router.get("/pocketid/callback")
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSession = Depends(get_db)):
issuer, client_id, client_secret = await _get_pocketid_config(db) issuer, client_id, client_secret = await _get_pocketid_config(db)
if not issuer: if not issuer:
raise HTTPException(status_code=404, detail="PocketID not configured") raise HTTPException(status_code=404, detail="PocketID not configured")
@@ -119,12 +225,18 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
"client_id": client_id, "client_secret": client_secret}, "client_id": client_id, "client_secret": client_secret},
) )
if resp.status_code != 200: if resp.status_code != 200:
print(f"PocketID token exchange failed ({resp.status_code}): {resp.text}")
raise HTTPException(status_code=400, detail="Token exchange failed") raise HTTPException(status_code=400, detail="Token exchange failed")
tokens = resp.json() tokens = resp.json()
access_token = tokens.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Token exchange failed")
userinfo_resp = await client.get( userinfo_resp = await client.get(
f"{issuer}/api/oidc/userinfo", f"{issuer}/api/oidc/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"}, headers={"Authorization": f"Bearer {access_token}"},
) )
if userinfo_resp.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to fetch user info")
userinfo = userinfo_resp.json() userinfo = userinfo_resp.json()
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -133,6 +245,42 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
email = userinfo.get("email") email = userinfo.get("email")
preferred_username = userinfo.get("preferred_username") or email preferred_username = userinfo.get("preferred_username") or email
# A missing subject means we cannot identify the user. Never continue, or the
# `pocketid_sub == sub` (== None → IS NULL) lookups below would match any
# password-only account and log the caller in as someone else.
if not sub:
return RedirectResponse(url="/login?auth_error=no_identity")
# ── Explicit account-link flow ──────────────────────────────────────────
# Initiated by an already-authenticated user from their profile. Attach the
# passkey to that account. No group gating here: this is identity linking,
# not access control, and the initiator is already an authorised user.
link_user_id = _decode_link_state(state)
if link_user_id is not None:
result = await db.execute(select(User).where(User.pocketid_sub == sub))
holder = result.scalar_one_or_none()
if holder and holder.id != link_user_id:
# This passkey is already attached to a different account.
return RedirectResponse(url="/login?auth_error=passkey_in_use")
result = await db.execute(select(User).where(User.id == link_user_id))
target = result.scalar_one_or_none()
if target is None:
return RedirectResponse(url="/login?auth_error=link_failed")
target.pocketid_sub = sub
target.hashed_password = None # disable password login once passkey is linked
if not target.email and email:
dup = await db.execute(
select(User).where(User.email == email, User.id != target.id)
)
if dup.scalar_one_or_none() is None:
target.email = email
return RedirectResponse(url="/profile?linked=1")
# Normal sign-in: require the signed, short-lived state we issued in
# /pocketid/login-url, so the callback can't be driven by an injected code.
if not _valid_login_state(state):
return RedirectResponse(url="/login?auth_error=invalid_state")
# Group gating: if an allowed group is configured, the user must be in it. # Group gating: if an allowed group is configured, the user must be in it.
allowed_group = await _get_allowed_group(db) allowed_group = await _get_allowed_group(db)
if allowed_group: if allowed_group:
@@ -151,6 +299,7 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
existing = result.scalar_one_or_none() existing = result.scalar_one_or_none()
if existing and existing.pocketid_sub is None: if existing and existing.pocketid_sub is None:
existing.pocketid_sub = sub existing.pocketid_sub = sub
existing.hashed_password = None # disable password login once passkey is linked
user = existing user = existing
# 3) Otherwise provision a new account with a collision-safe username. # 3) Otherwise provision a new account with a collision-safe username.
+82
View File
@@ -7,11 +7,25 @@ from datetime import datetime
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.models.user import User, GarminConnectConfig from app.models.user import User, GarminConnectConfig
router = APIRouter() router = APIRouter()
def _redis_client():
import redis as redis_lib
return redis_lib.Redis.from_url(settings.redis_url)
def sync_task_key(user_id: int) -> str:
return f"garmin_sync_task:{user_id}"
def sync_cancel_key(user_id: int) -> str:
return f"garmin_sync_cancel:{user_id}"
class GarminConfigIn(BaseModel): class GarminConfigIn(BaseModel):
email: str email: str
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing. password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing.
@@ -27,6 +41,7 @@ class GarminConfigOut(BaseModel):
sync_activities: bool sync_activities: bool
sync_wellness: bool sync_wellness: bool
sync_lookback_days: int sync_lookback_days: int
sync_interval_minutes: int # how often the automatic sync runs
last_sync_at: Optional[datetime] last_sync_at: Optional[datetime]
last_sync_status: Optional[str] last_sync_status: Optional[str]
connected: bool connected: bool
@@ -35,6 +50,17 @@ class GarminConfigOut(BaseModel):
from_attributes = True from_attributes = True
def _wants_more_history(old: int, new: int) -> bool:
"""True if `new` lookback requests older data than `old` (-1 = all-time)."""
if new == old:
return False
if new == -1: # all-time requested where it wasn't before
return True
if old == -1: # was all-time, now finite → narrower, not more
return False
return new > old
@router.get("/config", response_model=GarminConfigOut) @router.get("/config", response_model=GarminConfigOut)
async def get_config( async def get_config(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -48,6 +74,7 @@ async def get_config(
return GarminConfigOut( return GarminConfigOut(
email="", sync_enabled=False, sync_activities=True, email="", sync_enabled=False, sync_activities=True,
sync_wellness=True, sync_lookback_days=30, sync_wellness=True, sync_lookback_days=30,
sync_interval_minutes=settings.garmin_sync_interval_minutes,
last_sync_at=None, last_sync_status=None, connected=False, last_sync_at=None, last_sync_status=None, connected=False,
) )
return GarminConfigOut( return GarminConfigOut(
@@ -56,6 +83,7 @@ async def get_config(
sync_activities=cfg.sync_activities, sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness, sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30, 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_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status, last_sync_status=cfg.last_sync_status,
connected=True, connected=True,
@@ -107,6 +135,16 @@ async def save_config(
if not cfg: if not cfg:
raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup") raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup")
# If the user is now asking for MORE history than before, reset last_sync_at so
# the next sync treats it as a first sync and does a one-time backfill of the
# wider lookback window (then resumes cheap incremental syncs). Scheduled syncs
# otherwise only refresh the last day or two, so without this an increased
# lookback would never actually fetch the older data.
old_lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
if _wants_more_history(old_lookback, body.sync_lookback_days):
cfg.last_sync_at = None
cfg.last_sync_status = "Lookback increased — backfill on next sync"
cfg.sync_enabled = body.sync_enabled cfg.sync_enabled = body.sync_enabled
cfg.sync_activities = body.sync_activities cfg.sync_activities = body.sync_activities
cfg.sync_wellness = body.sync_wellness cfg.sync_wellness = body.sync_wellness
@@ -121,6 +159,7 @@ async def save_config(
sync_activities=cfg.sync_activities, sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness, sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30, 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_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status, last_sync_status=cfg.last_sync_status,
connected=True, connected=True,
@@ -157,4 +196,47 @@ async def trigger_sync(
from app.workers.tasks import sync_garmin_connect_user from app.workers.tasks import sync_garmin_connect_user
task = sync_garmin_connect_user.delay(current_user.id) task = sync_garmin_connect_user.delay(current_user.id)
# Track the active task id and clear any stale cancel flag so the new sync runs.
try:
r = _redis_client()
r.delete(sync_cancel_key(current_user.id))
r.set(sync_task_key(current_user.id), task.id, ex=3600)
except Exception:
pass
return {"task_id": task.id, "status": "queued"} return {"task_id": task.id, "status": "queued"}
@router.post("/cancel")
async def cancel_sync(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Request cancellation of the user's in-progress Garmin sync. The running task
checks this flag between items and aborts cooperatively."""
from app.workers.tasks import celery_app
try:
r = _redis_client()
r.set(sync_cancel_key(current_user.id), "1", ex=3600)
task_id = r.get(sync_task_key(current_user.id))
if task_id:
tid = task_id.decode() if isinstance(task_id, (bytes, bytearray)) else task_id
# terminate=False: don't kill a running worker mid-transaction; the
# cooperative flag handles an already-running task, and this revoke
# prevents a still-queued one from starting.
celery_app.control.revoke(tid, terminate=False)
except Exception:
pass
# Reflect intent immediately so the UI updates before the worker writes "Cancelled".
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if cfg:
cfg.last_sync_status = "Cancelling…"
await db.commit()
return {"status": "cancelling"}
+12 -4
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc
from pydantic import BaseModel from pydantic import BaseModel
@@ -20,6 +20,7 @@ class ProfileUpdate(BaseModel):
birth_year: Optional[int] = None birth_year: Optional[int] = None
height_cm: Optional[float] = None height_cm: Optional[float] = None
biological_sex: Optional[str] = None biological_sex: Optional[str] = None
goal_weight_kg: Optional[float] = None
class ProfileOut(BaseModel): class ProfileOut(BaseModel):
@@ -31,6 +32,7 @@ class ProfileOut(BaseModel):
birth_year: Optional[int] birth_year: Optional[int]
height_cm: Optional[float] height_cm: Optional[float]
biological_sex: Optional[str] biological_sex: Optional[str]
goal_weight_kg: Optional[float]
estimated_max_hr: Optional[int] estimated_max_hr: Optional[int]
is_admin: bool is_admin: bool
@@ -78,6 +80,10 @@ async def update_profile(
if body.biological_sex not in ('male', 'female', ''): if body.biological_sex not in ('male', 'female', ''):
raise HTTPException(400, "biological_sex must be 'male' or 'female'") raise HTTPException(400, "biological_sex must be 'male' or 'female'")
current_user.biological_sex = body.biological_sex or None current_user.biological_sex = body.biological_sex or None
if body.goal_weight_kg is not None:
if body.goal_weight_kg and not (20 <= body.goal_weight_kg <= 500):
raise HTTPException(400, "Goal weight must be 20500 kg")
current_user.goal_weight_kg = body.goal_weight_kg or None
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)
@@ -154,8 +160,10 @@ async def save_pocketid_config(
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
if body.client_id is not None: if body.client_id is not None:
current_user.pocketid_client_id = body.client_id or None current_user.pocketid_client_id = body.client_id or None
if body.client_secret is not None: # Only overwrite the secret when a non-empty value is supplied; a blank
current_user.pocketid_client_secret = body.client_secret or None # field means "keep the existing secret" (matches the UI hint).
if body.client_secret:
current_user.pocketid_client_secret = body.client_secret
if body.allowed_group is not None: if body.allowed_group is not None:
current_user.pocketid_allowed_group = body.allowed_group.strip() or None current_user.pocketid_allowed_group = body.allowed_group.strip() or None
await db.commit() await db.commit()
@@ -184,7 +192,7 @@ class WeightOut(BaseModel):
@router.get("/weight", response_model=List[WeightOut]) @router.get("/weight", response_model=List[WeightOut])
async def list_weight( async def list_weight(
limit: int = 365, limit: int = Query(365, ge=1, le=2000),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
+1 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime
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.models.user import User, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity from app.models.user import User, PersonalRecord, NamedRoute, HealthMetric, Activity
router = APIRouter() router = APIRouter()
+31 -323
View File
@@ -7,18 +7,11 @@ from datetime import datetime, timedelta, timezone
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.models.user import User, NamedRoute, RouteSegment, Activity from app.models.user import User, NamedRoute, Activity
router = APIRouter() router = APIRouter()
class SegmentCreate(BaseModel):
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str] = None
class RouteCreate(BaseModel): class RouteCreate(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
@@ -26,6 +19,11 @@ class RouteCreate(BaseModel):
activity_id: int activity_id: int
class RouteUpdate(BaseModel):
name: Optional[str] = None
sport_type: Optional[str] = None
class RouteOut(BaseModel): class RouteOut(BaseModel):
id: int id: int
name: str name: str
@@ -42,32 +40,6 @@ class RouteOut(BaseModel):
from_attributes = True from_attributes = True
class SegmentOut(BaseModel):
id: int
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str]
auto_generated: Optional[bool] = False
auto_generated_type: Optional[str] = None
class Config:
from_attributes = True
class AutoGenerateRequest(BaseModel):
type: str # "1km" | "turns" | "hills"
gradient_pct: float = 5.0
turn_angle_deg: float = 45.0
class SegmentTimeEntry(BaseModel):
activity_id: int
date: datetime
name: str
duration_s: float
@router.get("/", response_model=List[RouteOut]) @router.get("/", response_model=List[RouteOut])
async def list_routes( async def list_routes(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -179,6 +151,31 @@ async def get_route(
return route return route
@router.patch("/{route_id}", response_model=RouteOut)
async def update_route(
route_id: int,
body: RouteUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute).where(
NamedRoute.id == route_id,
NamedRoute.user_id == current_user.id,
)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
if body.name is not None and body.name.strip():
route.name = body.name.strip()
if body.sport_type is not None:
route.sport_type = body.sport_type
await db.commit()
await db.refresh(route)
return route
@router.get("/{route_id}/activities") @router.get("/{route_id}/activities")
async def route_activities( async def route_activities(
route_id: int, route_id: int,
@@ -281,292 +278,3 @@ async def assign_activity_to_route(
activity.named_route_id = route_id activity.named_route_id = route_id
await db.commit() await db.commit()
return {"status": "ok"} return {"status": "ok"}
async def _get_owned_route(route_id: int, user_id: int, db: AsyncSession) -> NamedRoute:
result = await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == user_id)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
async def list_segments(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
return result.scalars().all()
@router.post("/{route_id}/segments", response_model=SegmentOut)
async def create_segment(
route_id: int,
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
segment = RouteSegment(
route_id=route_id,
name=body.name,
start_distance_m=body.start_distance_m,
end_distance_m=body.end_distance_m,
description=body.description,
auto_generated=False,
)
db.add(segment)
await db.commit()
await db.refresh(segment)
return segment
@router.delete("/{route_id}/segments/{segment_id}", status_code=204)
async def delete_segment(
route_id: int,
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
result = await db.execute(
select(RouteSegment).where(
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
)
)
seg = result.scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
await db.delete(seg)
await db.commit()
@router.post("/{route_id}/segments/auto", response_model=List[SegmentOut])
async def auto_generate_segments(
route_id: int,
body: AutoGenerateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Auto-generate segments: 1km splits, turns, or hills."""
from app.services.route_matcher import (
generate_1km_segments, generate_turn_segments, generate_hill_segments,
)
from sqlalchemy import delete as sql_delete
route = await _get_owned_route(route_id, current_user.id, db)
if body.type not in ("1km", "turns", "hills"):
raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'")
# Clear only auto-generated segments of the same type so other auto types are preserved
await db.execute(
sql_delete(RouteSegment).where(
RouteSegment.route_id == route_id,
RouteSegment.auto_generated == True,
RouteSegment.auto_generated_type == body.type,
)
)
raw_segments: list[tuple[str, float, float]] = []
if body.type == "1km":
if not route.distance_m:
raise HTTPException(status_code=400, detail="Route has no distance recorded")
raw_segments = generate_1km_segments(route.reference_polyline or "", route.distance_m)
elif body.type == "turns":
if not route.reference_polyline:
raise HTTPException(status_code=400, detail="Route has no polyline")
raw_segments = generate_turn_segments(route.reference_polyline, body.turn_angle_deg)
elif body.type == "hills":
if not route.reference_polyline:
raise HTTPException(status_code=400, detail="Route has no polyline")
# Find most recent matched activity for elevation data
act_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(1)
)
act = act_result.scalar_one_or_none()
if not act:
raise HTTPException(status_code=400, detail="No matched activities found for elevation data")
from app.models.user import ActivityDataPoint
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == act.id)
.order_by(ActivityDataPoint.timestamp)
)
dps = dp_result.scalars().all()
dp_list = [{"distance_m": p.distance_m, "altitude_m": p.altitude_m} for p in dps]
raw_segments = generate_hill_segments(dp_list, body.gradient_pct)
new_segments = []
for name, start_m, end_m in raw_segments:
seg = RouteSegment(
route_id=route_id,
name=name,
start_distance_m=start_m,
end_distance_m=end_m,
auto_generated=True,
auto_generated_type=body.type,
)
db.add(seg)
new_segments.append(seg)
await db.commit()
for seg in new_segments:
await db.refresh(seg)
return new_segments
class SegmentBestOut(BaseModel):
segment_id: int
name: str
start_distance_m: float
end_distance_m: float
auto_generated: bool
best_s: Optional[float]
best_activity_id: Optional[int]
count: int
@router.get("/{route_id}/segment-bests", response_model=List[SegmentBestOut])
async def get_segment_bests(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return best time per segment across all matched activities for a route."""
from app.services.route_matcher import find_segment_times
from app.models.user import ActivityDataPoint
from collections import defaultdict
await _get_owned_route(route_id, current_user.id, db)
segs_result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
segments = segs_result.scalars().all()
if not segments:
return []
acts_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(20)
)
activities = acts_result.scalars().all()
if not activities:
return [
SegmentBestOut(
segment_id=s.id, name=s.name,
start_distance_m=s.start_distance_m, end_distance_m=s.end_distance_m,
auto_generated=bool(s.auto_generated), best_s=None, best_activity_id=None, count=0,
)
for s in segments
]
act_ids = [a.id for a in activities]
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id.in_(act_ids))
.order_by(ActivityDataPoint.activity_id, ActivityDataPoint.timestamp)
)
all_dps = dp_result.scalars().all()
# Group data points by activity_id
dp_by_act = defaultdict(list)
for dp in all_dps:
if dp.distance_m is not None:
dp_by_act[dp.activity_id].append({"distance_m": dp.distance_m, "timestamp": dp.timestamp})
bests = []
for seg in segments:
best_s = None
best_act_id = None
count = 0
for act_id in act_ids:
dp_list = dp_by_act.get(act_id, [])
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
if duration is not None:
count += 1
if best_s is None or duration < best_s:
best_s = duration
best_act_id = act_id
bests.append(SegmentBestOut(
segment_id=seg.id, name=seg.name,
start_distance_m=seg.start_distance_m, end_distance_m=seg.end_distance_m,
auto_generated=bool(seg.auto_generated),
best_s=best_s, best_activity_id=best_act_id, count=count,
))
return bests
@router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry])
async def get_segment_times(
route_id: int,
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return the last 10 times this segment was traversed across matched activities."""
from app.services.route_matcher import find_segment_times
from app.models.user import ActivityDataPoint
await _get_owned_route(route_id, current_user.id, db)
seg_result = await db.execute(
select(RouteSegment).where(
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
)
)
seg = seg_result.scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
acts_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(10)
)
activities = acts_result.scalars().all()
times = []
for act in activities:
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == act.id)
.order_by(ActivityDataPoint.timestamp)
)
dps = dp_result.scalars().all()
dp_list = [
{"distance_m": p.distance_m, "timestamp": p.timestamp}
for p in dps
if p.distance_m is not None
]
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
if duration:
times.append(SegmentTimeEntry(
activity_id=act.id,
date=act.start_time,
name=act.name,
duration_s=duration,
))
return times
+214
View File
@@ -0,0 +1,214 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
import polyline as polyline_lib
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, Segment, SegmentEffort, Activity, ActivityDataPoint
from app.services.route_matcher import haversine_m
router = APIRouter()
class SegmentCreate(BaseModel):
name: str
activity_id: int
start_distance_m: float
end_distance_m: float
class EffortOut(BaseModel):
activity_id: int
activity_name: str
date: Optional[datetime]
duration_s: float
rank: Optional[int]
class SegmentOut(BaseModel):
id: int
name: str
sport_type: Optional[str]
polyline: Optional[str]
distance_m: Optional[float]
created_from_activity_id: Optional[int]
effort_count: int = 0
best_s: Optional[float] = None
class SegmentDetailOut(SegmentOut):
leaderboard: List[EffortOut] = []
class ActivitySegmentOut(BaseModel):
segment_id: int
name: str
polyline: Optional[str]
distance_m: Optional[float]
duration_s: float
rank: Optional[int] # this activity's place on the leaderboard
best_s: Optional[float] # current gold time
effort_count: int
def _bbox(coords):
lats = [c[0] for c in coords]
lons = [c[1] for c in coords]
return {"min_lat": min(lats), "max_lat": max(lats), "min_lon": min(lons), "max_lon": max(lons)}
async def _own_segment(segment_id: int, user_id: int, db: AsyncSession) -> Segment:
seg = (await db.execute(
select(Segment).where(Segment.id == segment_id, Segment.user_id == user_id)
)).scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
return seg
@router.get("/", response_model=List[SegmentOut])
async def list_segments(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
segs = (await db.execute(
select(Segment).where(Segment.user_id == current_user.id).order_by(Segment.created_at.desc())
)).scalars().all()
out = []
for s in segs:
agg = (await db.execute(
select(func.count(SegmentEffort.id), func.min(SegmentEffort.duration_s))
.where(SegmentEffort.segment_id == s.id)
)).one()
out.append(SegmentOut(
id=s.id, name=s.name, sport_type=s.sport_type, polyline=s.polyline,
distance_m=s.distance_m, created_from_activity_id=s.created_from_activity_id,
effort_count=agg[0] or 0, best_s=agg[1],
))
return out
@router.post("/", response_model=SegmentOut)
async def create_segment(
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
activity = (await db.execute(
select(Activity).where(Activity.id == body.activity_id, Activity.user_id == current_user.id)
)).scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
lo, hi = sorted((body.start_distance_m, body.end_distance_m))
dps = (await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == activity.id)
.order_by(ActivityDataPoint.timestamp)
)).scalars().all()
coords = [
(p.latitude, p.longitude)
for p in dps
if p.distance_m is not None and p.latitude is not None and p.longitude is not None
and lo <= p.distance_m <= hi
]
if len(coords) < 2:
raise HTTPException(status_code=400, detail="Selected range has too few GPS points")
seg = Segment(
user_id=current_user.id,
name=body.name.strip() or "Segment",
sport_type=activity.sport_type,
polyline=polyline_lib.encode(coords),
start_lat=coords[0][0], start_lng=coords[0][1],
end_lat=coords[-1][0], end_lng=coords[-1][1],
distance_m=max(0.0, hi - lo),
bounding_box=_bbox(coords),
created_from_activity_id=activity.id,
)
db.add(seg)
await db.commit()
await db.refresh(seg)
# Match across all activities in the background.
from app.workers.tasks import match_segment
match_segment.delay(seg.id)
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=0, best_s=None,
)
@router.get("/by-activity/{activity_id}", response_model=List[ActivitySegmentOut])
async def segments_for_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Segments that this activity has an effort on, with the activity's place + the gold time."""
rows = (await db.execute(
select(Segment, SegmentEffort)
.join(SegmentEffort, SegmentEffort.segment_id == Segment.id)
.where(Segment.user_id == current_user.id, SegmentEffort.activity_id == activity_id)
.order_by(Segment.created_at.desc())
)).all()
out = []
for seg, effort in rows:
agg = (await db.execute(
select(func.count(SegmentEffort.id), func.min(SegmentEffort.duration_s))
.where(SegmentEffort.segment_id == seg.id)
)).one()
out.append(ActivitySegmentOut(
segment_id=seg.id, name=seg.name, polyline=seg.polyline, distance_m=seg.distance_m,
duration_s=effort.duration_s, rank=effort.rank,
best_s=agg[1], effort_count=agg[0] or 0,
))
return out
@router.get("/{segment_id}", response_model=SegmentDetailOut)
async def get_segment(
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
seg = await _own_segment(segment_id, current_user.id, db)
rows = (await db.execute(
select(SegmentEffort, Activity)
.join(Activity, Activity.id == SegmentEffort.activity_id)
.where(SegmentEffort.segment_id == seg.id)
.order_by(SegmentEffort.duration_s)
)).all()
leaderboard = [
EffortOut(
activity_id=e.activity_id, activity_name=a.name,
date=e.achieved_at or a.start_time, duration_s=e.duration_s, rank=e.rank,
)
for e, a in rows
]
return SegmentDetailOut(
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=len(leaderboard), best_s=leaderboard[0].duration_s if leaderboard else None,
leaderboard=leaderboard,
)
@router.delete("/{segment_id}", status_code=204)
async def delete_segment(
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
seg = await _own_segment(segment_id, current_user.id, db)
await db.delete(seg)
await db.commit()
+99 -33
View File
@@ -1,5 +1,4 @@
import os import os
import shutil
import zipfile 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
@@ -13,18 +12,68 @@ from app.workers.tasks import process_activity_file, process_garmin_health_zip
router = APIRouter() router = APIRouter()
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"} MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB upload cap
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB MAX_EXTRACT_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB total uncompressed cap (zip-bomb guard)
_CHUNK = 1024 * 1024
def _safe_name(filename: str) -> str:
"""Reduce an uploaded filename to a safe basename — no path traversal."""
name = os.path.basename((filename or "").replace("\\", "/"))
if not name or name in (".", ".."):
raise HTTPException(status_code=400, detail="Invalid filename")
return name
def save_upload(upload: UploadFile, dest_dir: Path) -> Path: def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
"""Stream an upload to disk under dest_dir, enforcing the size cap."""
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / upload.filename dest = dest_dir / _safe_name(upload.filename)
size = 0
with open(dest, "wb") as f: with open(dest, "wb") as f:
shutil.copyfileobj(upload.file, f) while True:
chunk = upload.file.read(_CHUNK)
if not chunk:
break
size += len(chunk)
if size > MAX_FILE_SIZE:
f.close()
dest.unlink(missing_ok=True)
raise HTTPException(status_code=413, detail="File exceeds the 500 MB limit")
f.write(chunk)
return dest return dest
def _safe_extract(zf: zipfile.ZipFile, dest_dir: Path) -> list[Path]:
"""Extract a zip safely: skip path-traversal members, cap total uncompressed
bytes (zip-bomb guard). Returns the list of extracted regular-file paths."""
dest_dir.mkdir(parents=True, exist_ok=True)
dest_root = dest_dir.resolve()
total = 0
extracted: list[Path] = []
for info in zf.infolist():
if info.is_dir():
continue
target = (dest_root / info.filename).resolve()
# Reject absolute paths and ../ traversal: the target must stay under dest_root.
if target != dest_root and dest_root not in target.parents:
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target, "wb") as out:
while True:
chunk = src.read(_CHUNK)
if not chunk:
break
total += len(chunk)
if total > MAX_EXTRACT_SIZE:
out.close()
target.unlink(missing_ok=True)
raise HTTPException(status_code=413, detail="Archive expands beyond the size limit")
out.write(chunk)
extracted.append(target)
return extracted
@router.post("/activity") @router.post("/activity")
async def upload_activity( async def upload_activity(
file: UploadFile = File(...), file: UploadFile = File(...),
@@ -62,35 +111,44 @@ async def upload_garmin_export(
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports" dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
dest = save_upload(file, dest_dir) dest = save_upload(file, dest_dir)
# Extract and queue all FIT files # Extract (safely) and queue all FIT files
extract_dir = dest_dir / f"garmin_{dest.stem}" extract_dir = dest_dir / f"garmin_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = [] task_ids = []
try:
with zipfile.ZipFile(dest) as zf: with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir) extracted = _safe_extract(zf, extract_dir)
for name in zf.namelist(): except zipfile.BadZipFile:
lower = name.lower() dest.unlink(missing_ok=True)
if lower.endswith(".fit"): raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
fit_path = extract_dir / name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit") has_health = False
for path in extracted:
suffix = path.suffix.lower()
if suffix == ".fit":
task = process_activity_file.delay(str(path), current_user.id, "fit")
task_ids.append(task.id) task_ids.append(task.id)
elif lower.endswith(".zip"): elif suffix == ".json":
has_health = True # Garmin wellness data is exported as JSON files
elif suffix == ".zip":
# Garmin exports nest activity FIT files inside sub-zips # Garmin exports nest activity FIT files inside sub-zips
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip) # (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
nested_zip_path = extract_dir / name nested_extract = path.parent / path.stem
nested_extract = nested_zip_path.parent / nested_zip_path.stem
nested_extract.mkdir(exist_ok=True)
try: try:
with zipfile.ZipFile(nested_zip_path) as nzf: with zipfile.ZipFile(path) as nzf:
nzf.extractall(nested_extract) nested = _safe_extract(nzf, nested_extract)
for nested_name in nzf.namelist():
if nested_name.lower().endswith(".fit"):
fit_path = nested_extract / nested_name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
task_ids.append(task.id)
except zipfile.BadZipFile: except zipfile.BadZipFile:
pass nested = []
for np in nested:
if np.suffix.lower() == ".fit":
task = process_activity_file.delay(str(np), current_user.id, "fit")
task_ids.append(task.id)
if not task_ids and not has_health:
raise HTTPException(
status_code=400,
detail="No fitness data found in this archive — make sure you uploaded your full Garmin Connect export ZIP",
)
# Queue health/wellness data extraction # 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)
@@ -116,19 +174,27 @@ async def upload_strava_export(
dest = save_upload(file, dest_dir) dest = save_upload(file, dest_dir)
extract_dir = dest_dir / f"strava_{dest.stem}" extract_dir = dest_dir / f"strava_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = [] task_ids = []
try:
with zipfile.ZipFile(dest) as zf: with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir) extracted = _safe_extract(zf, extract_dir)
for name in zf.namelist(): except zipfile.BadZipFile:
lower = name.lower() dest.unlink(missing_ok=True)
if lower.endswith(".fit") or lower.endswith(".gpx"): raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
file_path = extract_dir / name
ext = Path(name).suffix[1:] for path in extracted:
task = process_activity_file.delay(str(file_path), current_user.id, ext) suffix = path.suffix.lower()
if suffix in (".fit", ".gpx"):
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
task_ids.append(task.id) task_ids.append(task.id)
if not task_ids:
raise HTTPException(
status_code=400,
detail="No activity files (.fit or .gpx) found in this Strava archive",
)
return { return {
"status": "queued", "status": "queued",
"activity_tasks": len(task_ids), "activity_tasks": len(task_ids),
+4 -3
View File
@@ -19,7 +19,7 @@ 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,
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig, Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
) )
router = APIRouter() router = APIRouter()
@@ -122,12 +122,13 @@ async def delete_user(
# Ordered deletes: PersonalRecord and the activity/route child tables have no # Ordered deletes: PersonalRecord and the activity/route child tables have no
# cascade path from User, so remove them before the parents to avoid FK errors. # cascade path from User, so remove them before the parents to avoid FK errors.
activity_ids = select(Activity.id).where(Activity.user_id == user_id) activity_ids = select(Activity.id).where(Activity.user_id == user_id)
route_ids = select(NamedRoute.id).where(NamedRoute.user_id == user_id) segment_ids = select(Segment.id).where(Segment.user_id == user_id)
await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id)) await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids))) await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids))) await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids))) await db.execute(delete(SegmentEffort).where(SegmentEffort.segment_id.in_(segment_ids)))
await db.execute(delete(Segment).where(Segment.user_id == user_id))
await db.execute(delete(Activity).where(Activity.user_id == user_id)) await db.execute(delete(Activity).where(Activity.user_id == user_id))
await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id)) await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id))
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id)) await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
+2
View File
@@ -22,6 +22,8 @@ class Settings(BaseSettings):
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID") pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET") pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
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_sync_interval_minutes: int = Field(30, env="GARMIN_SYNC_INTERVAL_MINUTES")
# 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
+14 -7
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 from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users, segments
async def init_db(): async def init_db():
@@ -50,6 +50,15 @@ async def init_db():
except Exception as e: except Exception as e:
print(f"Column migration skipped: {e}") print(f"Column migration skipped: {e}")
# activities.moving_time_s column added after initial creation (timer time)
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS moving_time_s FLOAT"
))
except Exception as e:
print(f"activities.moving_time_s column migration skipped: {e}")
# health_metrics columns added after initial creation # health_metrics columns added after initial creation
try: try:
async with engine.begin() as conn: async with engine.begin() as conn:
@@ -82,17 +91,14 @@ async def init_db():
except Exception as e: except Exception as e:
print(f"users.pocketid_allowed_group column migration skipped: {e}") print(f"users.pocketid_allowed_group column migration skipped: {e}")
# route_segments auto_generated column added after initial creation # goal_weight_kg column on users added after initial creation
try: try:
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute(text( await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE" "ALTER TABLE users ADD COLUMN IF NOT EXISTS goal_weight_kg FLOAT"
))
await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)"
)) ))
except Exception as e: except Exception as e:
print(f"route_segments column migration skipped: {e}") print(f"users.goal_weight_kg 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:
@@ -225,6 +231,7 @@ 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(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.get("/health") @app.get("/health")
Binary file not shown.
+36 -11
View File
@@ -28,6 +28,7 @@ class User(Base):
birth_year = Column(Integer, nullable=True) birth_year = Column(Integer, nullable=True)
height_cm = Column(Float, nullable=True) height_cm = Column(Float, nullable=True)
biological_sex = Column(String(8), nullable=True) # 'male' | 'female' biological_sex = Column(String(8), nullable=True) # 'male' | 'female'
goal_weight_kg = Column(Float, nullable=True)
# PocketID config (stored per-user so admin can set via UI) # PocketID config (stored per-user so admin can set via UI)
pocketid_issuer = Column(String(512), nullable=True) pocketid_issuer = Column(String(512), nullable=True)
@@ -91,7 +92,8 @@ class Activity(Base):
start_time = Column(DateTime(timezone=True), nullable=False, index=True) start_time = Column(DateTime(timezone=True), nullable=False, index=True)
end_time = Column(DateTime(timezone=True), nullable=True) end_time = Column(DateTime(timezone=True), nullable=True)
distance_m = Column(Float, nullable=True) distance_m = Column(Float, nullable=True)
duration_s = Column(Float, nullable=True) duration_s = Column(Float, nullable=True) # total elapsed time (wall clock, incl. pauses)
moving_time_s = Column(Float, nullable=True) # timer time — excludes paused periods
elevation_gain_m = Column(Float, nullable=True) elevation_gain_m = Column(Float, nullable=True)
elevation_loss_m = Column(Float, nullable=True) elevation_loss_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True) avg_heart_rate = Column(Float, nullable=True)
@@ -172,22 +174,45 @@ class NamedRoute(Base):
user = relationship("User", back_populates="named_routes") user = relationship("User", back_populates="named_routes")
activities = relationship("Activity", back_populates="named_route") activities = relationship("Activity", back_populates="named_route")
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
class RouteSegment(Base): class Segment(Base):
__tablename__ = "route_segments" """A user-defined GPS segment (a stretch of road/trail) matched across activities."""
__tablename__ = "segments"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
route_id = Column(Integer, ForeignKey("named_routes.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)
start_distance_m = Column(Float, nullable=False) sport_type = Column(String(64), nullable=True)
end_distance_m = Column(Float, nullable=False) polyline = Column(Text, nullable=True) # encoded GPS geometry of the segment
description = Column(Text, nullable=True) start_lat = Column(Float, nullable=True)
auto_generated = Column(Boolean, default=False) start_lng = Column(Float, nullable=True)
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills' end_lat = Column(Float, nullable=True)
end_lng = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True)
bounding_box = Column(JSON, nullable=True) # {min_lat,max_lat,min_lon,max_lon}
created_from_activity_id = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
route = relationship("NamedRoute", back_populates="segments") efforts = relationship("SegmentEffort", back_populates="segment", cascade="all, delete-orphan")
class SegmentEffort(Base):
"""One activity's time over a segment."""
__tablename__ = "segment_efforts"
id = Column(Integer, primary_key=True)
segment_id = Column(Integer, ForeignKey("segments.id", ondelete="CASCADE"), nullable=False, index=True)
activity_id = Column(Integer, ForeignKey("activities.id", ondelete="CASCADE"), nullable=False, index=True)
duration_s = Column(Float, nullable=False)
achieved_at = Column(DateTime(timezone=True), nullable=True)
rank = Column(Integer, nullable=True) # 1/2/3 for podium, else null
__table_args__ = (
UniqueConstraint("segment_id", "activity_id", name="uq_segment_effort"),
)
segment = relationship("Segment", back_populates="efforts")
class PersonalRecord(Base): class PersonalRecord(Base):
+47 -10
View File
@@ -44,6 +44,33 @@ def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
return fv return fv
# Conservative average-speed ceilings (m/s) above which an activity was almost
# certainly recorded in a vehicle rather than under human power. Sports not
# listed fall back to the generous default.
_VEHICLE_SPEED_CEILINGS = {
"running": 8.0, # ~28.8 km/h — well above elite sprint pace sustained
"walking": 8.0,
"hiking": 8.0,
"cycling": 22.0, # ~79 km/h — beyond sustained amateur cycling
}
_VEHICLE_SPEED_DEFAULT = 25.0 # ~90 km/h
def _vehicle_reason(sport_type, avg_speed_ms, dist_m=None, dur_s=None) -> Optional[str]:
"""Return a human-readable reason if the average speed is implausibly fast for
the sport (i.e. the 'activity' looks like car/vehicle travel), else None."""
speed = _safe_float(avg_speed_ms)
if speed is None and dist_m and dur_s and float(dur_s) > 0:
speed = float(dist_m) / float(dur_s)
if speed is None or speed <= 0:
return None
ceiling = _VEHICLE_SPEED_CEILINGS.get(sport_type, _VEHICLE_SPEED_DEFAULT)
if speed > ceiling:
return (f"Looks like vehicle travel — average speed {speed * 3.6:.0f} km/h "
f"exceeds the plausible limit for {sport_type}")
return None
def _bounding_box(coords): def _bounding_box(coords):
if not coords: if not coords:
return None return None
@@ -210,12 +237,22 @@ def parse_fit_file(filepath: str) -> dict:
if start_time: if start_time:
name += " " + start_time.strftime("%Y-%m-%d") name += " " + start_time.strftime("%Y-%m-%d")
total_dist = _safe_float(get(session_data, "totalDistance", "total_distance"))
elapsed_s = _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time"))
# Timer time = time the device was actively recording (excludes auto/manual pauses).
moving_s = _safe_float(get(session_data, "totalTimerTime", "total_timer_time"))
avg_speed = _sanitize_speed(
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=total_dist, dur_s=elapsed_s,
)
return { return {
"name": name, "name": name,
"sport_type": sport_type, "sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None, "start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")), "distance_m": total_dist,
"duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")), "duration_s": elapsed_s,
"moving_time_s": moving_s,
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")), "elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")), "elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")), "avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
@@ -223,11 +260,7 @@ def parse_fit_file(filepath: str) -> dict:
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")), "avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")), "avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")), "normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
"avg_speed_ms": _sanitize_speed( "avg_speed_ms": avg_speed,
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")),
dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
),
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed", "max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
"enhancedMaxSpeed", "enhanced_max_speed")), "enhancedMaxSpeed", "enhanced_max_speed")),
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")), "avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")),
@@ -239,6 +272,7 @@ def parse_fit_file(filepath: str) -> dict:
"polyline": encoded_polyline, "polyline": encoded_polyline,
"bounding_box": bounding_box, "bounding_box": bounding_box,
"source_type": "fit", "source_type": "fit",
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
"data_points": normalized_points, "data_points": normalized_points,
"laps": normalized_laps, "laps": normalized_laps,
} }
@@ -310,20 +344,23 @@ def parse_gpx_file(filepath: str) -> dict:
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
sport = track.type.lower() if track.type else "running" sport = track.type.lower() if track.type else "running"
gpx_avg_speed = (total_dist / duration) if (total_dist and duration) else None
return { return {
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}", "name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
"sport_type": sport, "start_time": start_time_str, "sport_type": sport, "start_time": start_time_str,
"distance_m": total_dist, "duration_s": duration, "distance_m": total_dist, "duration_s": duration, "moving_time_s": None,
"elevation_gain_m": uphill, "elevation_loss_m": downhill, "elevation_gain_m": uphill, "elevation_loss_m": downhill,
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None, "avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
"max_heart_rate": max(hrs) if hrs else None, "max_heart_rate": max(hrs) if hrs else None,
"avg_cadence": None, "avg_power": None, "normalized_power": None, "avg_cadence": None, "avg_power": None, "normalized_power": None,
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None, "avg_speed_ms": gpx_avg_speed,
"max_speed_ms": None, "avg_temperature_c": None, "calories": None, "max_speed_ms": None, "avg_temperature_c": None, "calories": None,
"training_stress_score": None, "vo2max_estimate": None, "training_stress_score": None, "vo2max_estimate": None,
"polyline": encoded_polyline, "bounding_box": bounding_box, "polyline": encoded_polyline, "bounding_box": bounding_box,
"source_type": "gpx", "data_points": data_points, "laps": [], "source_type": "gpx",
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
"data_points": data_points, "laps": [],
} }
+32 -25
View File
@@ -17,6 +17,13 @@ from typing import Optional, Tuple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# On incremental syncs (last_sync_at is set) only re-fetch the last day or two
# rather than the full configured lookback window. A 1-day buffer means the
# window is "yesterday + today", which catches late-arriving / revised data
# (sleep finalised next morning, body battery, manual weight, HRV status, the
# midnight boundary) without re-pulling the same N days on every scheduled run.
INCREMENTAL_BUFFER_DAYS = 1
# ── Password encryption ───────────────────────────────────────────────────── # ── Password encryption ─────────────────────────────────────────────────────
@@ -78,9 +85,12 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
List activities from Garmin Connect, skip any already in the DB, download List activities from Garmin Connect, skip any already in the DB, download
FIT ZIPs for new ones, and queue them for processing. FIT ZIPs for new ones, and queue them for processing.
lookback_days controls the start date on every sync: lookback_days only sets the window on the FIRST sync (since is None):
-1 → full history back to 2010 on first sync, then incremental (since-1d) -1 → full history back to 2010
N → incremental (since-1d) when since is set; else last N days on first sync N → last N days
Every subsequent (incremental) sync re-fetches only the last
INCREMENTAL_BUFFER_DAYS days, regardless of lookback_days, to avoid
re-pulling the whole window on every scheduled run.
Returns the number of new activities queued. Returns the number of new activities queued.
""" """
import time import time
@@ -88,15 +98,11 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
from app.models.user import Activity from app.models.user import Activity
from sqlalchemy import select, func from sqlalchemy import select, func
if lookback_days == -1: if since:
# All-time: full pull on first sync, incremental thereafter # Incremental: just the recent buffer (cheap, dedup skips already-imported)
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1) start_date = (since - timedelta(days=INCREMENTAL_BUFFER_DAYS)).date()
elif since: elif lookback_days == -1:
# Use whichever is earlier: one day before last sync OR the configured lookback start_date = date(2010, 1, 1)
# window. This ensures increasing lookback_days actually fetches older data.
incremental = (since - timedelta(days=1)).date()
lookback = date.today() - timedelta(days=max(lookback_days, 1))
start_date = min(incremental, lookback)
else: else:
start_date = date.today() - timedelta(days=max(lookback_days, 1)) start_date = date.today() - timedelta(days=max(lookback_days, 1))
end_date = date.today() end_date = date.today()
@@ -195,21 +201,20 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each
day in the window and upsert into health_metrics. day in the window and upsert into health_metrics.
lookback_days controls the window on every sync: lookback_days only sets the window on the FIRST sync (since is None):
-1 → full history back to 2010 on first sync, then incremental (since-1d) -1 → full history back to 2010
N → incremental (since-1d) when since is set; else last N days on first sync N → last N days
Every subsequent (incremental) sync re-fetches only the last
INCREMENTAL_BUFFER_DAYS days so late-finalised data (sleep, body battery,
weight) is corrected without re-pulling the whole window each run.
Returns the number of days upserted. Returns the number of days upserted.
""" """
from sqlalchemy import text from sqlalchemy import text
if lookback_days == -1: if since:
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1) start_date = (since - timedelta(days=INCREMENTAL_BUFFER_DAYS)).date()
elif since: elif lookback_days == -1:
# Use whichever is earlier: one day before last sync OR the configured lookback start_date = date(2010, 1, 1)
# window. This ensures increasing lookback_days actually fetches older data.
incremental = (since - timedelta(days=1)).date()
lookback = date.today() - timedelta(days=max(lookback_days, 1))
start_date = min(incremental, lookback)
else: else:
start_date = date.today() - timedelta(days=max(lookback_days, 1)) start_date = date.today() - timedelta(days=max(lookback_days, 1))
days = (date.today() - start_date).days + 1 days = (date.today() - start_date).days + 1
@@ -555,8 +560,10 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
if spo2 and 50 < float(spo2) <= 100: if spo2 and 50 < float(spo2) <= 100:
row["spo2_avg"] = float(spo2) row["spo2_avg"] = float(spo2)
# Sleep score — structure varies across firmware # Sleep score — Garmin nests it under dailySleepDTO.sleepScores on most
scores = sleep_data.get("sleepScores") or sleep_data.get("sleepScore") # firmware, but some return it at the top level; check both.
scores = (dto.get("sleepScores") or sleep_data.get("sleepScores")
or dto.get("sleepScore") or sleep_data.get("sleepScore"))
if isinstance(scores, dict): if isinstance(scores, dict):
overall = scores.get("overall") or scores.get("qualityScore") overall = scores.get("overall") or scores.get("qualityScore")
if isinstance(overall, dict): if isinstance(overall, dict):
+58 -173
View File
@@ -95,39 +95,72 @@ def routes_are_similar(
return dist < dtw_threshold_m return dist < dtw_threshold_m
def find_segment_times( def match_segment_in_activity(
data_points: list[dict], seg_coords: list[tuple],
start_dist_m: float, act_coords: list[tuple],
end_dist_m: float, act_times: list,
tol_m: float = 30.0,
) -> Optional[float]: ) -> Optional[float]:
""" """
Given activity data points (with cumulative distance_m), Determine whether an activity track traverses a segment's GPS geometry in the
find the time to traverse from start_dist_m to end_dist_m. segment's own direction, and if so how long the fastest such traversal took.
Returns duration in seconds, or None if not found. Works even when the activity's overall route differs — only the overlapping
""" stretch matters.
start_time = None
end_time = None
for p in data_points: seg_coords: [(lat, lon), ...] segment geometry (start → end).
dist = p.get("distance_m") act_coords: [(lat, lon), ...] activity track, in time order.
ts = p.get("timestamp") act_times: parallel list of datetimes for act_coords.
if dist is None or ts is None:
Strategy: for every pass of the activity near the segment START, walk forward
accumulating path length; accept the traversal only if the activity reaches the
segment END after covering roughly the segment's own length (so an out-and-back
route can't match an early start to a late finish), and the intermediate segment
points are passed in order. Returns the shortest valid traversal time, or None.
"""
n = len(act_coords)
m = len(seg_coords)
if n < 2 or m < 2:
return None
start_pt, end_pt = seg_coords[0], seg_coords[-1]
seg_len = sum(haversine_m(seg_coords[k], seg_coords[k + 1]) for k in range(m - 1))
if seg_len <= 0:
return None
near_start = lambda i: haversine_m(act_coords[i], start_pt) <= tol_m
# One candidate entry per pass through the start region (first point of each run).
entries = [i for i in range(n) if near_start(i) and (i == 0 or not near_start(i - 1))]
best = None
for si in entries:
path = 0.0
ei = None
for i in range(si + 1, n):
path += haversine_m(act_coords[i - 1], act_coords[i])
if path > seg_len * 1.5: # wandered too far without finishing → wrong pass/direction
break
if path >= seg_len * 0.6 and haversine_m(act_coords[i], end_pt) <= tol_m:
ei = i
break
if ei is None:
continue continue
if start_time is None and dist >= start_dist_m: # Confirm the activity follows the segment shape in order between the anchors.
start_time = ts ok = True
for frac in (0.25, 0.5, 0.75):
if start_time is not None and dist >= end_dist_m: sp = seg_coords[int(frac * (m - 1))]
end_time = ts if not any(haversine_m(act_coords[k], sp) <= tol_m for k in range(si, ei + 1)):
ok = False
break break
if not ok:
continue
if start_time and end_time: dur = (act_times[ei] - act_times[si]).total_seconds()
from datetime import datetime if dur > 0 and (best is None or dur < best):
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time best = dur
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
return (t2 - t1).total_seconds()
return None return best
def find_best_split_time( def find_best_split_time(
@@ -174,154 +207,6 @@ def find_best_split_time(
return best return best
def _bearing(p1: tuple, p2: tuple) -> float:
"""Compass bearing in degrees (0-360) from p1 to p2."""
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
dlon = lon2 - lon1
x = math.sin(dlon) * math.cos(lat2)
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
return math.degrees(math.atan2(x, y)) % 360
def generate_1km_segments(encoded_polyline: str, total_dist_m: float) -> list[tuple[str, float, float]]:
"""Generate 1-km splits along a route. Returns list of (name, start_m, end_m)."""
if not encoded_polyline:
return []
km_count = int(total_dist_m / 1000)
segments = []
for i in range(km_count):
segments.append((f"km {i + 1}", float(i * 1000), float((i + 1) * 1000)))
remainder = total_dist_m - km_count * 1000
if remainder >= 200:
segments.append((f"km {km_count + 1}", float(km_count * 1000), total_dist_m))
return segments
def generate_turn_segments(
encoded_polyline: str,
turn_angle_deg: float = 45.0,
) -> list[tuple[str, float, float]]:
"""Detect sharp turns in a route polyline. Returns list of (name, start_m, end_m)."""
coords = decode_polyline_to_coords(encoded_polyline)
if len(coords) < 3:
return []
cum_dists = [0.0]
for i in range(1, len(coords)):
cum_dists.append(cum_dists[-1] + haversine_m(coords[i - 1], coords[i]))
total = cum_dists[-1]
HALF_WINDOW = 100.0 # metres either side of candidate turn point
turn_centers: list[float] = []
for i in range(1, len(coords) - 1):
# Find index ~HALF_WINDOW before and after
start_i = i
while start_i > 0 and cum_dists[i] - cum_dists[start_i] < HALF_WINDOW:
start_i -= 1
end_i = i
while end_i < len(coords) - 1 and cum_dists[end_i] - cum_dists[i] < HALF_WINDOW:
end_i += 1
if start_i == i or end_i == i:
continue
b1 = _bearing(coords[start_i], coords[i])
b2 = _bearing(coords[i], coords[end_i])
diff = abs(b2 - b1) % 360
if diff > 180:
diff = 360 - diff
if diff >= turn_angle_deg:
turn_centers.append(cum_dists[i])
if not turn_centers:
return []
# Cluster turns within 150 m of each other → one segment per cluster
clusters: list[list[float]] = [[turn_centers[0]]]
for d in turn_centers[1:]:
if d - clusters[-1][-1] < 150:
clusters[-1].append(d)
else:
clusters.append([d])
segments = []
for cluster in clusters:
center = sum(cluster) / len(cluster)
start = max(0.0, center - HALF_WINDOW)
end = min(total, center + HALF_WINDOW)
segments.append((f"Turn at {center / 1000:.1f} km", start, end))
return segments
def generate_hill_segments(
data_points: list[dict],
gradient_pct: float = 5.0,
) -> list[tuple[str, float, float]]:
"""
Detect uphill sections using activity data points (with altitude_m + distance_m).
Returns list of (name, start_m, end_m).
"""
pts = [
(p["distance_m"], p["altitude_m"])
for p in data_points
if p.get("distance_m") is not None and p.get("altitude_m") is not None
]
if len(pts) < 10:
return []
pts.sort(key=lambda x: x[0])
dists = [p[0] for p in pts]
alts = [p[1] for p in pts]
# Smooth altitude with a sliding window to reduce GPS noise
SMOOTH = 10
smooth_alts = []
for i in range(len(alts)):
lo, hi = max(0, i - SMOOTH), min(len(alts), i + SMOOTH + 1)
smooth_alts.append(sum(alts[lo:hi]) / (hi - lo))
grad_threshold = gradient_pct / 100.0
MIN_HILL_M = 200.0
in_hill = False
hill_start_idx = 0
segments = []
for i in range(1, len(dists)):
d_dist = dists[i] - dists[i - 1]
if d_dist <= 0:
continue
grad = (smooth_alts[i] - smooth_alts[i - 1]) / d_dist
if grad >= grad_threshold and not in_hill:
in_hill = True
hill_start_idx = i - 1
elif grad < grad_threshold and in_hill:
length = dists[i - 1] - dists[hill_start_idx]
if length >= MIN_HILL_M:
gain = round(smooth_alts[i - 1] - smooth_alts[hill_start_idx])
start_km = dists[hill_start_idx] / 1000
segments.append((
f"Hill at {start_km:.1f} km (+{gain} m)",
dists[hill_start_idx],
dists[i - 1],
))
in_hill = False
if in_hill:
length = dists[-1] - dists[hill_start_idx]
if length >= MIN_HILL_M:
gain = round(smooth_alts[-1] - smooth_alts[hill_start_idx])
start_km = dists[hill_start_idx] / 1000
segments.append((
f"Hill at {start_km:.1f} km (+{gain} m)",
dists[hill_start_idx],
dists[-1],
))
return segments
STANDARD_DISTANCES = [ STANDARD_DISTANCES = [
(400, "400m"), (400, "400m"),
(800, "800m"), (800, "800m"),
+243 -1
View File
@@ -25,7 +25,8 @@ celery_app.conf.update(
beat_schedule={ beat_schedule={
"sync-garmin-connect": { "sync-garmin-connect": {
"task": "sync_all_garmin_connect", "task": "sync_all_garmin_connect",
"schedule": 1800.0, # every 30 minutes # Interval is configurable via GARMIN_SYNC_INTERVAL_MINUTES (default 30 min)
"schedule": float(settings.garmin_sync_interval_minutes * 60),
}, },
}, },
) )
@@ -79,6 +80,11 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
if not parsed.get("start_time"): if not parsed.get("start_time"):
return {"status": "skipped", "reason": "no start_time", "file": file_path} return {"status": "skipped", "reason": "no start_time", "file": file_path}
# Reject activities whose average speed is implausible for the sport (e.g. a
# car journey accidentally recorded). Surfaced to the upload UI as the reason.
if parsed.get("rejected_reason"):
return {"status": "skipped", "reason": parsed["rejected_reason"], "file": file_path}
with SyncSessionLocal() as db: with SyncSessionLocal() as db:
start_time = datetime.fromisoformat(parsed["start_time"]) start_time = datetime.fromisoformat(parsed["start_time"])
@@ -127,6 +133,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
start_time=start_time, start_time=start_time,
distance_m=parsed.get("distance_m"), distance_m=parsed.get("distance_m"),
duration_s=parsed.get("duration_s"), duration_s=parsed.get("duration_s"),
moving_time_s=parsed.get("moving_time_s"),
elevation_gain_m=parsed.get("elevation_gain_m"), elevation_gain_m=parsed.get("elevation_gain_m"),
elevation_loss_m=parsed.get("elevation_loss_m"), elevation_loss_m=parsed.get("elevation_loss_m"),
avg_heart_rate=parsed.get("avg_heart_rate"), avg_heart_rate=parsed.get("avg_heart_rate"),
@@ -199,6 +206,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
compute_personal_records.delay(activity_id, user_id, parsed) compute_personal_records.delay(activity_id, user_id, parsed)
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"): if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
detect_route.delay(activity_id, user_id) detect_route.delay(activity_id, user_id)
match_activity_segments.delay(activity_id, user_id)
return {"activity_id": activity_id, "status": "ok"} return {"activity_id": activity_id, "status": "ok"}
@@ -379,6 +387,19 @@ def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
start_time_str = parsed.get("start_time") start_time_str = parsed.get("start_time")
start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc) start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc)
# GPS can over/under-measure relative to the activity's official distance
# (e.g. a 5 km run whose GPS track sums to 5.8 km), which would otherwise
# produce a bogus "best 5 km" split. Scale the distance stream so its max
# matches the recorded total before computing splits.
if total_dist > 0 and data_points:
gps_max = max((p.get("distance_m") or 0) for p in data_points)
if gps_max > 0 and abs(gps_max - total_dist) / total_dist > 0.02:
factor = total_dist / gps_max
data_points = [
{**p, "distance_m": p["distance_m"] * factor} if p.get("distance_m") is not None else p
for p in data_points
]
best_splits = compute_best_splits(data_points, total_dist) best_splits = compute_best_splits(data_points, total_dist)
with SyncSessionLocal() as db: with SyncSessionLocal() as db:
@@ -412,6 +433,145 @@ def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
db.commit() db.commit()
def _recompute_segment_ranks(db, segment_id: int):
"""Assign rank 1/2/3 to the three fastest efforts on a segment, null to the rest."""
from app.models.user import SegmentEffort
from sqlalchemy import select
efforts = db.execute(
select(SegmentEffort)
.where(SegmentEffort.segment_id == segment_id)
.order_by(SegmentEffort.duration_s)
).scalars().all()
for i, e in enumerate(efforts):
e.rank = (i + 1) if i < 3 else None
def _activity_track(db, activity_id):
"""Return (coords, times) for an activity's GPS track in time order."""
from app.models.user import ActivityDataPoint
from sqlalchemy import select
dps = db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == activity_id)
.order_by(ActivityDataPoint.timestamp)
).scalars().all()
coords, times = [], []
for p in dps:
if p.latitude is not None and p.longitude is not None and p.timestamp is not None:
coords.append((p.latitude, p.longitude))
times.append(p.timestamp)
return coords, times
def _upsert_effort(db, segment_id, activity, duration_s):
from app.models.user import SegmentEffort
from sqlalchemy import select
existing = db.execute(
select(SegmentEffort).where(
SegmentEffort.segment_id == segment_id,
SegmentEffort.activity_id == activity.id,
)
).scalar_one_or_none()
if existing:
existing.duration_s = duration_s
existing.achieved_at = activity.start_time
else:
db.add(SegmentEffort(
segment_id=segment_id,
activity_id=activity.id,
duration_s=duration_s,
achieved_at=activity.start_time,
))
@celery_app.task(name="match_segment")
def match_segment(segment_id: int):
"""Match one segment against every eligible activity and (re)build its leaderboard."""
from app.services.route_matcher import (
match_segment_in_activity, bounding_boxes_overlap, decode_polyline_to_coords,
)
from app.core.database import SyncSessionLocal
from app.models.user import Segment, Activity
from sqlalchemy import select
with SyncSessionLocal() as db:
seg = db.execute(select(Segment).where(Segment.id == segment_id)).scalar_one_or_none()
if not seg or not seg.polyline:
return {"status": "no_segment"}
seg_coords = decode_polyline_to_coords(seg.polyline)
acts = db.execute(
select(Activity).where(
Activity.user_id == seg.user_id,
Activity.sport_type == seg.sport_type,
Activity.polyline != None,
)
).scalars().all()
matched = 0
for act in acts:
if seg.bounding_box and act.bounding_box and not bounding_boxes_overlap(seg.bounding_box, act.bounding_box):
continue
coords, times = _activity_track(db, act.id)
if len(coords) < 2:
continue
dur = match_segment_in_activity(seg_coords, coords, times)
if dur is None:
continue
_upsert_effort(db, seg.id, act, dur)
matched += 1
db.commit()
_recompute_segment_ranks(db, seg.id)
db.commit()
return {"status": "ok", "matched": matched}
@celery_app.task(name="match_activity_segments")
def match_activity_segments(activity_id: int, user_id: int):
"""Match a newly-ingested activity against all of the user's existing segments."""
from app.services.route_matcher import (
match_segment_in_activity, bounding_boxes_overlap, decode_polyline_to_coords,
)
from app.core.database import SyncSessionLocal
from app.models.user import Segment, Activity
from sqlalchemy import select
with SyncSessionLocal() as db:
act = db.execute(select(Activity).where(Activity.id == activity_id)).scalar_one_or_none()
if not act or not act.polyline:
return {"status": "no_polyline"}
coords, times = _activity_track(db, act.id)
if len(coords) < 2:
return {"status": "no_track"}
segs = db.execute(
select(Segment).where(
Segment.user_id == user_id,
Segment.sport_type == act.sport_type,
)
).scalars().all()
touched = []
for seg in segs:
if not seg.polyline:
continue
if seg.bounding_box and act.bounding_box and not bounding_boxes_overlap(seg.bounding_box, act.bounding_box):
continue
dur = match_segment_in_activity(decode_polyline_to_coords(seg.polyline), coords, times)
if dur is None:
continue
_upsert_effort(db, seg.id, act, dur)
touched.append(seg.id)
db.commit()
for sid in touched:
_recompute_segment_ranks(db, sid)
db.commit()
return {"status": "ok", "matched_segments": len(touched)}
@celery_app.task(name="process_garmin_health_zip") @celery_app.task(name="process_garmin_health_zip")
def process_garmin_health_zip(zip_path: str, user_id: int): def process_garmin_health_zip(zip_path: str, user_id: int):
"""Extract wellness data from a Garmin Connect export ZIP.""" """Extract wellness data from a Garmin Connect export ZIP."""
@@ -497,6 +657,10 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
db.commit() db.commit()
class SyncCancelled(Exception):
"""Raised inside a Garmin sync when the user has requested cancellation."""
@celery_app.task(name="sync_garmin_connect_user") @celery_app.task(name="sync_garmin_connect_user")
def sync_garmin_connect_user(user_id: int): def sync_garmin_connect_user(user_id: int):
"""Sync Garmin Connect data (activities + wellness) for one user.""" """Sync Garmin Connect data (activities + wellness) for one user."""
@@ -507,6 +671,20 @@ def sync_garmin_connect_user(user_id: int):
from sqlalchemy import select from sqlalchemy import select
from datetime import datetime, timezone from datetime import datetime, timezone
# Cooperative-cancellation flag (set by POST /garmin-sync/cancel).
cancel_key = f"garmin_sync_cancel:{user_id}"
try:
import redis as redis_lib
_redis = redis_lib.Redis.from_url(settings.redis_url)
except Exception:
_redis = None
def _cancelled():
try:
return bool(_redis and _redis.exists(cancel_key))
except Exception:
return False
with SyncSessionLocal() as db: with SyncSessionLocal() as db:
cfg = db.execute( cfg = db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id) select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)
@@ -544,9 +722,13 @@ def sync_garmin_connect_user(user_id: int):
errors = [] errors = []
def _set_status(text): def _set_status(text):
# Checked between items: abort the sync if cancellation was requested.
if _cancelled():
raise SyncCancelled()
cfg.last_sync_status = text cfg.last_sync_status = text
db.commit() db.commit()
try:
if sync_acts: if sync_acts:
_set_status("Syncing activities...") _set_status("Syncing activities...")
try: try:
@@ -555,6 +737,8 @@ def sync_garmin_connect_user(user_id: int):
lookback_days=lookback, lookback_days=lookback,
status_callback=_set_status, status_callback=_set_status,
) )
except SyncCancelled:
raise
except Exception as exc: except Exception as exc:
errors.append(f"activities: {exc}") errors.append(f"activities: {exc}")
@@ -566,9 +750,23 @@ def sync_garmin_connect_user(user_id: int):
lookback_days=lookback, lookback_days=lookback,
status_callback=_set_status, status_callback=_set_status,
) )
except SyncCancelled:
raise
except Exception as exc: except Exception as exc:
errors.append(f"wellness: {exc}") errors.append(f"wellness: {exc}")
db.rollback() # recover session so the final status commit can succeed db.rollback() # recover session so the final status commit can succeed
except SyncCancelled:
db.rollback()
cfg.last_sync_at = datetime.now(timezone.utc)
cfg.last_sync_status = "Cancelled"
db.commit()
try:
if _redis:
_redis.delete(cancel_key)
except Exception:
pass
return {"status": "cancelled",
"activities_queued": activities_queued, "wellness_days": wellness_days}
cfg.last_sync_at = datetime.now(timezone.utc) cfg.last_sync_at = datetime.now(timezone.utc)
cfg.last_sync_status = ( cfg.last_sync_status = (
@@ -623,3 +821,47 @@ def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
activity.hr_zones = new_zones activity.hr_zones = new_zones
db.commit() db.commit()
@celery_app.task(name="backfill_moving_time")
def backfill_moving_time(user_id: int = None):
"""Populate moving_time_s for existing FIT-sourced activities by re-reading the
timer time from their stored source files. Idempotent — skips activities that
already have a value or whose source file is missing/unreadable."""
import os
from app.services.fit_parser import parse_fit_file
from app.core.database import SyncSessionLocal
from app.models.user import Activity
from sqlalchemy import select
updated, skipped = 0, 0
with SyncSessionLocal() as db:
q = select(Activity).where(
Activity.moving_time_s.is_(None),
Activity.source_type == "fit",
Activity.source_file.isnot(None),
)
if user_id is not None:
q = q.where(Activity.user_id == user_id)
activities = db.execute(q).scalars().all()
for activity in activities:
path = activity.source_file
if not path or not os.path.exists(path):
skipped += 1
continue
try:
parsed = parse_fit_file(path)
except Exception:
skipped += 1
continue
mt = parsed.get("moving_time_s")
if mt:
activity.moving_time_s = mt
updated += 1
else:
skipped += 1
db.commit()
return {"status": "ok", "updated": updated, "skipped": skipped}
+3468
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -8,7 +8,6 @@ import ActivitiesPage from './pages/ActivitiesPage'
import ActivityDetailPage from './pages/ActivityDetailPage' import ActivityDetailPage from './pages/ActivityDetailPage'
import HealthPage from './pages/HealthPage' import HealthPage from './pages/HealthPage'
import RoutesPage from './pages/RoutesPage' import RoutesPage from './pages/RoutesPage'
import SegmentsPage from './pages/SegmentsPage'
import RecordsPage from './pages/RecordsPage' import RecordsPage from './pages/RecordsPage'
import UploadPage from './pages/UploadPage' import UploadPage from './pages/UploadPage'
import ProfilePage from './pages/ProfilePage' import ProfilePage from './pages/ProfilePage'
@@ -36,7 +35,6 @@ export default function App() {
<Route path="activities/:id" element={<ActivityDetailPage />} /> <Route path="activities/:id" element={<ActivityDetailPage />} />
<Route path="health" element={<HealthPage />} /> <Route path="health" element={<HealthPage />} />
<Route path="routes" element={<RoutesPage />} /> <Route path="routes" element={<RoutesPage />} />
<Route path="segments" element={<SegmentsPage />} />
<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 />} />
+155 -38
View File
@@ -24,6 +24,33 @@ const TILE_LAYERS = {
}, },
} }
// Tile options tuned for smoother panning/zooming: keep a larger off-screen
// buffer of tiles and don't defer loads until the map is idle.
const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false }
// Slow → fast colour ramp for speed-coloured routes (red → purple).
export const SPEED_STOPS = ['#ef4444', '#f97316', '#22c55e', '#3b82f6', '#a855f7']
// CSS gradient string for the speed legend.
export const SPEED_GRADIENT = `linear-gradient(to right, ${SPEED_STOPS.join(', ')})`
const SPEED_LEVELS = 24 // quantisation steps → smooth gradient while limiting layer count
function lerpColor(c1, c2, t) {
const a = parseInt(c1.slice(1), 16), b = parseInt(c2.slice(1), 16)
const r = Math.round(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * t)
const g = Math.round(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * t)
const bl = Math.round((a & 255) + ((b & 255) - (a & 255)) * t)
return `#${((1 << 24) + (r << 16) + (g << 8) + bl).toString(16).slice(1)}`
}
function rampColor(t) {
t = Math.max(0, Math.min(1, t))
const seg = t * (SPEED_STOPS.length - 1)
const i = Math.min(SPEED_STOPS.length - 2, Math.floor(seg))
return lerpColor(SPEED_STOPS[i], SPEED_STOPS[i + 1], seg - i)
}
function decodePolyline(encoded) { function decodePolyline(encoded) {
const coords = [] const coords = []
let index = 0, lat = 0, lng = 0 let index = 0, lat = 0, lng = 0
@@ -39,43 +66,107 @@ function decodePolyline(encoded) {
return coords return coords
} }
function drawRoute(map, polyline, sportType, trackRef) {
if (trackRef.current) {
trackRef.current.remove()
trackRef.current = null
}
if (!polyline) return
const coords = decodePolyline(polyline)
if (!coords.length) return
trackRef.current = L.polyline(coords, {
color: sportColor(sportType),
weight: 3,
opacity: 0.9,
}).addTo(map)
map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
const dot = (color) => L.divIcon({ const dot = (color) => L.divIcon({
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`, html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
iconSize: [12, 12], iconAnchor: [6, 6], className: '', iconSize: [12, 12], iconAnchor: [6, 6], className: '',
}) })
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(map)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(map) // Pulsing target dot shown under the cursor while drawing a segment, so the user
// can see exactly which track point a click will snap to.
const SEG_TARGET_ICON = L.divIcon({
html: '<div style="width:14px;height:14px;background:#22c55e;border:2px solid #fff;border-radius:50%;box-shadow:0 0 8px rgba(34,197,94,0.9)"></div>',
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
})
// Nearest recorded GPS point to a lat/lng (squared planar distance is fine at
// these scales). Mirrors nearestDistance() in ActivityDetailPage.
function nearestPoint(points, lat, lng) {
let best = null, bestD = Infinity
for (const p of points) {
if (p.latitude == null || p.longitude == null) continue
const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2
if (d < bestD) { bestD = d; best = p }
}
return best
} }
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) { function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
if (trackRef.current) {
trackRef.current.remove()
trackRef.current = null
}
// Prefer the data-point track when colouring by speed; fall back to the encoded polyline.
const speedPts = (colorMode === 'speed' && dataPoints)
? dataPoints.filter(p => p.latitude != null && p.longitude != null)
: []
const group = L.layerGroup()
if (speedPts.length >= 2 && speedPts.some(p => p.speed_ms != null)) {
const speeds = speedPts.map(p => p.speed_ms).filter(s => s != null && s > 0)
speeds.sort((a, b) => a - b)
// Clamp the range to the 5th95th percentile so a couple of GPS spikes don't wash out the ramp.
const lo = speeds[Math.floor(speeds.length * 0.05)] ?? 0
const hi = speeds[Math.floor(speeds.length * 0.95)] ?? lo + 1
const levelOf = (s) => {
const t = (hi > lo) ? (((s ?? lo) - lo) / (hi - lo)) : 0.5
return Math.round(Math.max(0, Math.min(1, t)) * SPEED_LEVELS)
}
// Group consecutive points into runs of the same colour level → one polyline per run.
let runStart = 0
let runLevel = levelOf(speedPts[0].speed_ms)
const flush = (end) => {
const coords = speedPts.slice(runStart, end + 1).map(p => [p.latitude, p.longitude])
if (coords.length >= 2) {
L.polyline(coords, { color: rampColor(runLevel / SPEED_LEVELS), weight: 3, opacity: 0.95 }).addTo(group)
}
}
for (let i = 1; i < speedPts.length; i++) {
const level = levelOf(speedPts[i].speed_ms)
if (level !== runLevel) {
flush(i) // include current point so runs join up
runStart = i
runLevel = level
}
}
flush(speedPts.length - 1)
const coords = speedPts.map(p => [p.latitude, p.longitude])
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
group.addTo(map)
trackRef.current = group
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
return
}
// Solid single-colour route from the encoded polyline.
if (!polyline) return
const coords = decodePolyline(polyline)
if (!coords.length) return
L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }).addTo(group)
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
group.addTo(map)
trackRef.current = group
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
}
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'street', colorMode = 'speed', onMapClick }) {
const mapRef = useRef(null) const mapRef = useRef(null)
const mapInstanceRef = useRef(null) const mapInstanceRef = useRef(null)
const markerRef = useRef(null) const markerRef = useRef(null)
const segTargetRef = useRef(null)
const trackRef = useRef(null) const trackRef = useRef(null)
const tileLayerRef = useRef(null) const tileLayerRef = useRef(null)
const polylineRef = useRef(polyline) const drawArgsRef = useRef({ polyline, dataPoints, sportType, colorMode })
const sportTypeRef = useRef(sportType) const clickRef = useRef(onMapClick)
useEffect(() => { polylineRef.current = polyline }, [polyline]) drawArgsRef.current = { polyline, dataPoints, sportType, colorMode }
useEffect(() => { sportTypeRef.current = sportType }, [sportType]) useEffect(() => { clickRef.current = onMapClick }, [onMapClick])
useEffect(() => { useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return if (!mapRef.current || mapInstanceRef.current) return
@@ -83,13 +174,34 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
mapInstanceRef.current = L.map(mapRef.current, { mapInstanceRef.current = L.map(mapRef.current, {
zoomControl: true, zoomControl: true,
attributionControl: true, attributionControl: true,
preferCanvas: true,
}) })
const tile = TILE_LAYERS.dark const tile = TILE_LAYERS.street
tileLayerRef.current = L.tileLayer(tile.url, { tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
attribution: tile.attribution, .addTo(mapInstanceRef.current)
maxZoom: 19,
}).addTo(mapInstanceRef.current) mapInstanceRef.current.on('click', (e) => {
if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng })
})
// While in segment-create mode, show a target dot snapped to the nearest
// track point so the user can see what a click will select.
mapInstanceRef.current.on('mousemove', (e) => {
const pts = drawArgsRef.current.dataPoints
if (!clickRef.current || !pts?.length) return
const np = nearestPoint(pts, e.latlng.lat, e.latlng.lng)
if (!np) return
if (segTargetRef.current) {
segTargetRef.current.setLatLng([np.latitude, np.longitude])
} else {
segTargetRef.current = L.marker([np.latitude, np.longitude],
{ icon: SEG_TARGET_ICON, interactive: false }).addTo(mapInstanceRef.current)
}
})
mapInstanceRef.current.on('mouseout', () => {
if (segTargetRef.current) { segTargetRef.current.remove(); segTargetRef.current = null }
})
return () => { return () => {
mapInstanceRef.current?.remove() mapInstanceRef.current?.remove()
@@ -97,21 +209,26 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
} }
}, []) }, [])
// Clear the target dot when leaving segment-create mode.
useEffect(() => {
if (!onMapClick && segTargetRef.current) {
segTargetRef.current.remove()
segTargetRef.current = null
}
}, [onMapClick])
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current) return if (!mapInstanceRef.current) return
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark 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, { tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
attribution: tile.attribution, .addTo(mapInstanceRef.current)
maxZoom: 19,
}).addTo(mapInstanceRef.current)
drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef)
}, [mapType]) }, [mapType])
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current) return if (!mapInstanceRef.current) return
drawRoute(mapInstanceRef.current, polyline, sportType, trackRef) drawRoute(mapInstanceRef.current, drawArgsRef.current, trackRef)
}, [polyline, sportType]) }, [polyline, sportType, colorMode, dataPoints])
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
+22 -4
View File
@@ -2,8 +2,9 @@ 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 }) { export default function LapTable({ laps, sportType, lapBests }) {
const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase()) const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
const hasBests = lapBests && Object.keys(lapBests).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">
@@ -12,6 +13,8 @@ export default function LapTable({ laps, sportType }) {
<th className="text-left pb-2 font-medium">Lap</th> <th className="text-left pb-2 font-medium">Lap</th>
<th className="text-right pb-2 font-medium">Distance</th> <th className="text-right pb-2 font-medium">Distance</th>
<th className="text-right pb-2 font-medium">Time</th> <th className="text-right pb-2 font-medium">Time</th>
{hasBests && <th className="text-right pb-2 font-medium">Best</th>}
{hasBests && <th className="text-right pb-2 font-medium">Δ</th>}
<th className="text-right pb-2 font-medium">Pace</th> <th className="text-right pb-2 font-medium">Pace</th>
<th className="text-right pb-2 font-medium">Avg HR</th> <th className="text-right pb-2 font-medium">Avg HR</th>
<th className="text-right pb-2 font-medium">Cadence</th> <th className="text-right pb-2 font-medium">Cadence</th>
@@ -19,11 +22,25 @@ export default function LapTable({ laps, sportType }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{laps.map((lap) => ( {laps.map((lap) => {
const best = hasBests ? lapBests[String(lap.lap_number)] : null
const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null
const isBest = delta != null && delta <= 0.5
return (
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"> <tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
<td className="py-2 text-gray-400">{lap.lap_number}</td> <td className="py-2 text-gray-400">{lap.lap_number}</td>
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td> <td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td> <td className={`py-2 text-right ${isBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
{hasBests && (
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
)}
{hasBests && (
<td className={`py-2 text-right font-mono ${
delta == null ? 'text-gray-700' : isBest ? 'text-yellow-400' : delta < 0 ? 'text-green-400' : 'text-red-400'
}`}>
{delta == null ? '--' : isBest ? '🏆' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
</td>
)}
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td> <td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</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>
@@ -37,7 +54,8 @@ export default function LapTable({ laps, sportType }) {
</td> </td>
)} )}
</tr> </tr>
))} )
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -1,21 +1,49 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { import {
ComposedChart, Line, 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 } from '../../utils/format'
// Running cadence colour bands (steps per minute). Cadence is stored halved for
// running, so spm = stored × 2.
function cadenceColor(spm) {
if (spm < 153) return '#ef4444' // red — slow
if (spm < 164) return '#f97316' // orange — moderate
if (spm < 174) return '#22c55e' // green — good recreational
if (spm < 184) return '#3b82f6' // blue — experienced
return '#a855f7' // purple — elite
}
const renderCadenceDot = (props) => {
const { cx, cy, payload } = props
if (cx == null || cy == null || payload?.cadence == null) return null
return <circle cx={cx} cy={cy} r={2} fill={cadenceColor(payload.cadence * 2)} />
}
function downsample(points, maxPoints = 500) { function downsample(points, maxPoints = 500) {
if (points.length <= maxPoints) return points if (points.length <= maxPoints) return points
const step = Math.ceil(points.length / maxPoints) const step = Math.ceil(points.length / maxPoints)
return points.filter((_, i) => i % step === 0) return points.filter((_, i) => i % step === 0)
} }
function buildChartData(dataPoints, activeMetrics) { // mm:ss label for the time-based X-axis (stationary/indoor activities).
function fmtSeconds(s) {
const m = Math.floor(s / 60)
return `${m}:${String(Math.floor(s % 60)).padStart(2, '0')}`
}
function buildChartData(dataPoints, activeMetrics, useTimeAxis) {
const base = useTimeAxis
? new Date(dataPoints.find(p => p.timestamp)?.timestamp || 0).getTime()
: 0
return dataPoints return dataPoints
.filter(p => p.timestamp) .filter(p => p.timestamp)
.map(p => { .map(p => {
const row = { distance_m: p.distance_m ?? 0 } const x = useTimeAxis
? (new Date(p.timestamp).getTime() - base) / 1000
: (p.distance_m ?? 0)
const row = { x }
for (const key of activeMetrics) { for (const key of activeMetrics) {
row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
} }
@@ -23,12 +51,12 @@ function buildChartData(dataPoints, activeMetrics) {
}) })
} }
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => { const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, useTimeAxis }) => {
if (!active || !payload?.length) return null if (!active || !payload?.length) return null
if (onHover) onHover(label) if (onHover) onHover(label)
return ( return (
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl"> <div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p> <p className="text-gray-400 mb-1">{useTimeAxis ? fmtSeconds(label) : `${(label / 1000).toFixed(2)} km`}</p>
{payload.map(entry => { {payload.map(entry => {
const metric = metrics.find(m => m.key === entry.dataKey) const metric = metrics.find(m => m.key === entry.dataKey)
if (!metric || entry.value == null) return null if (!metric || entry.value == null) return null
@@ -52,9 +80,17 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover })
} }
export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) { export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) {
// Stationary/indoor activities (HIIT, strength, trainer) record no distance, so
// plotting against distance collapses every sample onto x=0. Fall back to an
// elapsed-time X-axis when there's no distance spread.
const useTimeAxis = useMemo(
() => !dataPoints.some(p => p.distance_m != null && p.distance_m > 0),
[dataPoints]
)
const chartData = useMemo(() => const chartData = useMemo(() =>
downsample(buildChartData(dataPoints, activeMetrics)), downsample(buildChartData(dataPoints, activeMetrics, useTimeAxis)),
[dataPoints, activeMetrics] [dataPoints, activeMetrics, useTimeAxis]
) )
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key)) const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
@@ -103,10 +139,10 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics"> <ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics">
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis <XAxis
dataKey="distance_m" dataKey="x"
type="number" type="number"
domain={['dataMin', 'dataMax']} domain={['dataMin', 'dataMax']}
tickFormatter={v => `${(v / 1000).toFixed(1)}`} tickFormatter={v => useTimeAxis ? fmtSeconds(v) : `${(v / 1000).toFixed(1)}`}
tick={{ fontSize: 10, fill: '#6b7280' }} tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
@@ -130,9 +166,16 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
}} }}
/> />
<Tooltip <Tooltip
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />} content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} useTimeAxis={useTimeAxis} />}
isAnimationActive={false} isAnimationActive={false}
/> />
{metric.key === 'cadence' && sportType === 'running' ? (
<>
{/* 165 spm guide → 82.5 in stored (halved) units */}
<ReferenceLine y={82.5} stroke="#22c55e" strokeDasharray="4 4" strokeWidth={1.5} />
<Scatter dataKey="cadence" shape={renderCadenceDot} isAnimationActive={false} />
</>
) : (
<Line <Line
type="monotone" type="monotone"
dataKey={metric.key} dataKey={metric.key}
@@ -142,12 +185,13 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
isAnimationActive={false} isAnimationActive={false}
connectNulls={false} connectNulls={false}
/> />
)}
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) )
})} })}
<p className="text-xs text-gray-600 text-center">Distance (km)</p> <p className="text-xs text-gray-600 text-center">{useTimeAxis ? 'Elapsed time (mm:ss)' : 'Distance (km)'}</p>
</div> </div>
) )
} }
@@ -0,0 +1,101 @@
import { Link } from 'react-router-dom'
import { formatDuration, formatDate } from '../../utils/format'
// Compact +M:SS / +SS gap label (fastest effort shows nothing).
function gapLabel(gapS) {
if (gapS == null || gapS <= 0.5) return null
return `+${formatDuration(gapS)}`
}
export default function RouteLeaderboard({ data }) {
if (!data || !data.top || data.top.length === 0) return null
const { current, total, top } = data
const currentGap = current ? gapLabel(current.gap_s) : null
const inTop10 = current && current.rank <= 10
return (
<div className="overflow-x-auto">
{/* This activity's standing on the route */}
{current && (
<div className="mb-3 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 flex items-center justify-between gap-3">
<div>
<div className="text-xs text-emerald-300/80">This activity</div>
<div className="text-lg font-semibold text-emerald-300 font-mono">
{formatDuration(current.duration_s)}
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-gray-200">
#{current.rank} <span className="text-gray-500">of {total}</span>
</div>
<div className="text-xs">
{currentGap == null
? <span className="text-yellow-400">🏆 Fastest</span>
: <span className="text-gray-400">{currentGap} off fastest</span>}
</div>
</div>
</div>
)}
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800">
<th className="text-left pb-2 font-medium">#</th>
<th className="text-left pb-2 font-medium">Date</th>
<th className="text-right pb-2 font-medium">Time</th>
<th className="text-right pb-2 font-medium">Δ</th>
</tr>
</thead>
<tbody>
{top.map((e) => {
const gap = gapLabel(e.gap_s)
return (
<tr
key={e.activity_id}
className={`border-b border-gray-800/50 transition-colors ${
e.is_current
? 'bg-emerald-500/15 hover:bg-emerald-500/20'
: 'hover:bg-gray-800/30'
}`}
>
<td className={`py-2 ${e.rank === 1 ? 'text-yellow-400' : 'text-gray-400'}`}>
{e.rank === 1 ? '🏆' : e.rank}
</td>
<td className="py-2">
<Link
to={`/activities/${e.activity_id}`}
className={`hover:underline ${e.is_current ? 'text-emerald-300 font-medium' : 'text-gray-300'}`}
>
{formatDate(e.start_time)}
</Link>
</td>
<td className={`py-2 text-right font-mono ${e.is_current ? 'text-emerald-300 font-semibold' : 'text-gray-200'}`}>
{formatDuration(e.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{gap == null ? '--' : gap}
</td>
</tr>
)
})}
{/* If this activity ranks outside the top 10, still surface its row. */}
{current && !inTop10 && (
<tr className="border-t border-gray-700 bg-emerald-500/15">
<td className="py-2 text-emerald-300">{current.rank}</td>
<td className="py-2">
<span className="text-emerald-300 font-medium">{formatDate(current.start_time)}</span>
</td>
<td className="py-2 text-right font-mono text-emerald-300 font-semibold">
{formatDuration(current.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{gapLabel(current.gap_s) ?? '--'}
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,145 @@
import { useState, Fragment } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../../utils/api'
import { formatDuration, formatDistance } from '../../utils/format'
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
// Compact +M:SS gap label (fastest effort shows nothing) — mirrors RouteLeaderboard.
function gapLabel(gapS) {
if (gapS == null || gapS <= 0.5) return null
return `+${formatDuration(gapS)}`
}
// Top-10 leaderboard for a single segment, styled to match RouteLeaderboard.
function Leaderboard({ segmentId, activityId }) {
const { data } = useQuery({
queryKey: ['segment', segmentId],
queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data),
})
if (!data) return <p className="text-xs text-gray-600 py-2">Loading</p>
if (!data.leaderboard?.length) return <p className="text-xs text-gray-600 py-2">No efforts yet still matching.</p>
const top = data.leaderboard.slice(0, 10)
const fastest = top[0].duration_s
return (
<table className="w-full text-sm mt-1 mb-2">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800">
<th className="text-left pb-2 font-medium">#</th>
<th className="text-left pb-2 font-medium">Activity</th>
<th className="text-right pb-2 font-medium">Time</th>
<th className="text-right pb-2 font-medium">Δ</th>
</tr>
</thead>
<tbody>
{top.map((e) => {
const isCurrent = e.activity_id === activityId
const gap = gapLabel(e.duration_s - fastest)
return (
<tr
key={e.activity_id}
className={`border-b border-gray-800/50 transition-colors ${
isCurrent ? 'bg-emerald-500/15 hover:bg-emerald-500/20' : 'hover:bg-gray-800/30'
}`}
>
<td className={`py-2 ${e.rank === 1 ? 'text-yellow-400' : 'text-gray-400'}`}>
{e.rank === 1 ? '🏆' : e.rank}
</td>
<td className="py-2">
<Link
to={`/activities/${e.activity_id}`}
className={`hover:underline ${isCurrent ? 'text-emerald-300 font-medium' : 'text-gray-300'}`}
>
{e.activity_name}
</Link>
</td>
<td className={`py-2 text-right font-mono ${isCurrent ? 'text-emerald-300 font-semibold' : 'text-gray-200'}`}>
{formatDuration(e.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{gap == null ? '--' : gap}
</td>
</tr>
)
})}
</tbody>
</table>
)
}
export default function SegmentsPanel({ segments, activityId }) {
const qc = useQueryClient()
const [open, setOpen] = useState(null)
const remove = async (id) => {
if (!confirm('Delete this segment?')) return
await api.delete(`/segments/${id}`)
qc.invalidateQueries()
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800">
<th className="text-left pb-2 font-medium">Segment</th>
<th className="text-right pb-2 font-medium">This run</th>
<th className="text-right pb-2 font-medium">Best</th>
<th className="text-right pb-2 font-medium">Place</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{segments.map(seg => {
const isPodium = seg.rank && seg.rank <= 3
const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null
const isOpen = open === seg.segment_id
return (
<Fragment key={seg.segment_id}>
<tr
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
>
<td className="py-2">
<button
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
className="text-left text-gray-300 hover:text-white"
>
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
{seg.name}
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m)}</span>
</button>
</td>
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{formatDuration(seg.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
</td>
<td className="py-2 text-right font-mono">
{isPodium
? <span title="Podium time on this activity" className="text-yellow-400">{MEDALS[seg.rank]}</span>
: delta != null
? <span className="text-gray-500">+{formatDuration(delta)}</span>
: <span className="text-gray-700">--</span>}
</td>
<td className="py-2 text-right">
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment"></button>
</td>
</tr>
{isOpen && (
<tr>
<td colSpan={5} className="bg-gray-950/40">
<Leaderboard segmentId={seg.segment_id} activityId={activityId} />
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)
}
+80 -11
View File
@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom' import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../hooks/useAuth' import { useAuthStore } from '../../hooks/useAuth'
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
const nav = [ const nav = [
{ to: '/', label: 'Dashboard', icon: '📊', exact: true }, { to: '/', label: 'Dashboard', icon: '📊', exact: true },
{ to: '/activities', label: 'Activities', icon: '🏃' }, { to: '/activities', label: 'Activities', icon: '🏃' },
{ to: '/health', label: 'Health', icon: '❤️' }, { to: '/health', label: 'Health', icon: '❤️' },
{ to: '/routes', label: 'Routes', icon: '🗺️' }, { to: '/routes', label: 'Routes', icon: '🗺️' },
{ to: '/segments', label: 'Segments', icon: '📏' },
{ to: '/records', label: 'Records', icon: '🏆' }, { to: '/records', label: 'Records', icon: '🏆' },
{ to: '/upload', label: 'Import', icon: '⬆️' }, { to: '/upload', label: 'Import', icon: '⬆️' },
{ to: '/profile', label: 'Profile', icon: '⚙️' }, { to: '/profile', label: 'Profile', icon: '⚙️' },
@@ -16,44 +17,112 @@ const nav = [
export default function Layout() { export default function Layout() {
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const { inProgress, status, startPolling, stopPolling } = useSyncStore()
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
useEffect(() => {
startPolling()
return () => stopPolling()
}, [])
const toggleCollapsed = () => {
setCollapsed(c => {
const next = !c
localStorage.setItem('navCollapsed', next ? '1' : '0')
return next
})
}
const handleLogout = () => { const handleLogout = () => {
logout() logout()
navigate('/login') navigate('/login')
} }
const role = user?.is_admin ? 'Administrator' : 'Member'
return ( return (
<div className="flex h-screen overflow-hidden bg-gray-950"> <div className="flex h-screen overflow-hidden bg-gray-950">
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col"> <aside className={`${collapsed ? 'w-16' : 'w-56'} flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col transition-[width] duration-200`}>
<div className="px-4 py-5 border-b border-gray-800"> <div className={`flex items-center border-b border-gray-800 px-3 py-5 ${collapsed ? 'justify-center' : 'justify-between'}`}>
{!collapsed && (
<h1 className="text-lg font-bold text-white tracking-tight"> <h1 className="text-lg font-bold text-white tracking-tight">
<span className="text-blue-400">Mile</span>Vault <span className="text-blue-400">Mile</span>Vault
</h1> </h1>
{user && <p className="text-xs text-gray-500 mt-0.5">@{user.username}{user.is_admin ? ' · admin' : ''}</p>} )}
<button onClick={toggleCollapsed}
title={collapsed ? 'Expand menu' : 'Collapse menu'}
className="text-gray-500 hover:text-white transition-colors text-lg leading-none">
{collapsed ? '»' : '«'}
</button>
</div> </div>
<nav className="flex-1 py-4 overflow-y-auto"> <nav className="flex-1 py-4 overflow-y-auto">
{nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ to, label, icon, exact }) => ( {nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ to, label, icon, exact }) => (
<NavLink key={to} to={to} end={exact} <NavLink key={to} to={to} end={exact} title={collapsed ? label : undefined}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${ `flex items-center gap-3 py-2.5 text-sm transition-colors ${collapsed ? 'justify-center px-0' : 'px-4'} ${
isActive isActive
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400' ? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400'
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800' : 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
}` }`
}> }>
<span>{icon}</span> <span>{icon}</span>
{label} {!collapsed && label}
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div className="px-4 py-4 border-t border-gray-800"> {inProgress && !collapsed && (
<button onClick={handleLogout} <div className="px-4 py-3 border-t border-gray-800 space-y-1.5">
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors"> <div className="flex items-center gap-2 text-xs text-blue-400">
Sign out <span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Garmin sync
</div>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${syncProgressPct(status)}%` }} />
</div>
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
</div>
)}
{inProgress && collapsed && (
<div className="flex justify-center py-3 border-t border-gray-800" title={`Garmin sync: ${status || 'starting…'}`}>
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400 animate-pulse" />
</div>
)}
{/* Logged-in user + privilege level */}
<div className="border-t border-gray-800 p-3">
{user ? (
collapsed ? (
<div className="flex justify-center" title={`${user.username} · ${role}`}>
<span className="w-8 h-8 rounded-full bg-blue-600/20 text-blue-300 flex items-center justify-center text-sm font-semibold uppercase">
{user.username?.[0] || '?'}
</span>
</div>
) : (
<div className="flex items-center gap-2.5">
<span className="w-8 h-8 flex-shrink-0 rounded-full bg-blue-600/20 text-blue-300 flex items-center justify-center text-sm font-semibold uppercase">
{user.username?.[0] || '?'}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{user.username}</p>
<p className={`text-xs ${user.is_admin ? 'text-amber-400' : 'text-gray-500'}`}>{role}</p>
</div>
<button onClick={handleLogout} title="Sign out"
className="text-gray-500 hover:text-red-400 transition-colors text-sm">
</button> </button>
</div> </div>
)
) : null}
{collapsed && (
<button onClick={handleLogout} title="Sign out"
className="w-full mt-2 text-center text-gray-500 hover:text-red-400 transition-colors text-sm">
</button>
)}
</div>
</aside> </aside>
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
+95
View File
@@ -0,0 +1,95 @@
import { create } from 'zustand'
import api from '../utils/api'
// A status string is "terminal" when the sync has finished (success, partial, error, or cancelled).
const isTerminal = (s) =>
s.startsWith('OK') || s.startsWith('Partial') || s.startsWith('Auth error') ||
s.startsWith('Credentials') || s.startsWith('Connected') || s.startsWith('Cancelled')
// Map a Garmin sync status string to an approximate completion percentage.
export function syncProgressPct(status) {
if (!status) return 3
if (status.startsWith('Connecting')) return 10
if (status.startsWith('Syncing activities')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m && +m[2] > 0) return 15 + Math.round((+m[1] / +m[2]) * 30)
return 20
}
if (status.startsWith('Syncing wellness')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m && +m[2] > 0) return 45 + Math.round((+m[1] / +m[2]) * 45)
return 50
}
return 3
}
export function syncPhase(status) {
if (!status) return -1
if (status.startsWith('Connecting') || status.startsWith('Starting')) return 0
if (status.startsWith('Syncing activities')) return 1
if (status.startsWith('Syncing wellness')) return 2
return -1
}
let pollTimer = null
export const useSyncStore = create((set, get) => ({
status: '',
inProgress: false,
connected: false,
lastSyncAt: null,
email: '',
poll: async () => {
try {
const { data } = await api.get('/garmin-sync/config')
const status = data?.last_sync_status ?? ''
const inProgress = !!status && !isTerminal(status)
set({
status, inProgress,
connected: !!data?.connected,
lastSyncAt: data?.last_sync_at ?? null,
email: data?.email ?? '',
})
return inProgress
} catch {
return get().inProgress
}
},
// Adaptive polling: fast while a sync runs, slow when idle. Runs for the
// lifetime of the app (started by Layout) so the floating bar stays accurate
// no matter which page you're on.
startPolling: () => {
if (pollTimer) return
const tick = async () => {
const inProgress = await get().poll()
pollTimer = setTimeout(tick, inProgress ? 3000 : 20000)
}
tick()
},
stopPolling: () => {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null }
},
trigger: async () => {
set({ inProgress: true, status: 'Starting sync…' })
try {
await api.post('/garmin-sync/trigger')
} catch {
set({ inProgress: false })
return
}
get().stopPolling()
get().startPolling()
},
cancel: async () => {
set({ status: 'Cancelling…' })
try {
await api.post('/garmin-sync/cancel')
} catch { /* ignore — poll will reflect the true state */ }
get().poll()
},
}))
+143 -59
View File
@@ -1,25 +1,28 @@
import { useParams, Link } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { useQuery } 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'
import ActivityMap from '../components/activity/ActivityMap' import ActivityMap, { SPEED_GRADIENT } from '../components/activity/ActivityMap'
import MetricTimeline from '../components/activity/MetricTimeline' import MetricTimeline from '../components/activity/MetricTimeline'
import HRZoneBar from '../components/activity/HRZoneBar' import HRZoneBar from '../components/activity/HRZoneBar'
import LapTable from '../components/activity/LapTable' import LapTable from '../components/activity/LapTable'
import SegmentsPanel from '../components/activity/SegmentsPanel'
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, sportIcon,
} from '../utils/format' } from '../utils/format'
function segmentTime(points, startM, endM) { // Find the cumulative distance along the track nearest a clicked lat/lng.
let t0 = null function nearestDistance(points, lat, lng) {
let best = null, bestD = Infinity
for (const p of points) { for (const p of points) {
if (t0 === null && p.distance_m >= startM) t0 = new Date(p.timestamp).getTime() if (p.latitude == null || p.longitude == null || p.distance_m == null) continue
if (t0 !== null && p.distance_m >= endM) const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2
return (new Date(p.timestamp).getTime() - t0) / 1000 if (d < bestD) { bestD = d; best = p.distance_m }
} }
return null return best
} }
const METRICS = [ const METRICS = [
@@ -36,7 +39,12 @@ export default function ActivityDetailPage() {
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('dark') const [mapType, setMapType] = useState('street')
const [colorMode, setColorMode] = useState('speed')
const [segCreate, setSegCreate] = useState(false)
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
const [segName, setSegName] = useState('')
const qc = useQueryClient()
const { data: activity, isLoading } = useQuery({ const { data: activity, isLoading } = useQuery({
queryKey: ['activity', id], queryKey: ['activity', id],
@@ -55,18 +63,49 @@ export default function ActivityDetailPage() {
enabled: !!activity, enabled: !!activity,
}) })
const { data: segments } = useQuery({ const { data: actSegments } = useQuery({
queryKey: ['segments', activity?.named_route_id], queryKey: ['activity-segments', id],
queryFn: () => api.get(`/routes/${activity.named_route_id}/segments`).then(r => r.data), queryFn: () => api.get(`/segments/by-activity/${id}`).then(r => r.data),
enabled: !!activity,
})
const { data: lapBests } = useQuery({
queryKey: ['lap-bests', id],
queryFn: () => api.get(`/activities/${id}/lap-bests`).then(r => r.data),
enabled: !!activity?.named_route_id, enabled: !!activity?.named_route_id,
}) })
const { data: segmentBests } = useQuery({ const { data: routeBoard } = useQuery({
queryKey: ['segment-bests', activity?.named_route_id], queryKey: ['route-leaderboard', id],
queryFn: () => api.get(`/routes/${activity.named_route_id}/segment-bests`).then(r => r.data), queryFn: () => api.get(`/activities/${id}/route-leaderboard`).then(r => r.data),
enabled: !!activity?.named_route_id, enabled: !!activity?.named_route_id,
}) })
const handleMapClick = ({ lat, lng }) => {
if (!segCreate || !dataPoints) return
const dist = nearestDistance(dataPoints, lat, lng)
if (dist == null) return
setSegPoints(prev => (prev.length >= 2 ? [{ distance_m: dist }] : [...prev, { distance_m: dist }]))
}
const [segError, setSegError] = useState('')
const createSegment = async () => {
const [a, b] = segPoints
setSegError('')
try {
await api.post('/segments/', {
name: segName.trim() || 'Segment',
activity_id: Number(id),
start_distance_m: a.distance_m,
end_distance_m: b.distance_m,
})
setSegCreate(false); setSegPoints([]); setSegName('')
qc.invalidateQueries({ queryKey: ['activity-segments', id] })
} catch (e) {
setSegError(e.response?.data?.detail || 'Failed to create segment')
}
}
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]
@@ -104,7 +143,11 @@ export default function ActivityDetailPage() {
{/* Stats — all on one row */} {/* Stats — all on one row */}
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3"> <div className="grid grid-cols-5 lg:grid-cols-10 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} /> <StatCard label="Distance" value={formatDistance(activity.distance_m)} />
<StatCard label="Time" value={formatDuration(activity.duration_s)} /> <StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
sub={activity.moving_time_s ? 'moving' : undefined} />
{activity.moving_time_s != null && Math.abs(activity.moving_time_s - (activity.duration_s ?? 0)) >= 1 && (
<StatCard label="Elapsed" value={formatDuration(activity.duration_s)} />
)}
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} /> <StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} /> <StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" /> <StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
@@ -123,7 +166,8 @@ export default function ActivityDetailPage() {
</div> </div>
)} )}
{/* Map with controls */} {/* Map with controls — only when the activity has a GPS track */}
{activity.polyline && activity.distance_m > 0 ? (
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800"> <div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
{/* Map toolbar */} {/* Map toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800"> <div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
@@ -140,9 +184,31 @@ export default function ActivityDetailPage() {
{t} {t}
</button> </button>
))} ))}
{dataPoints?.length > 0 && (
<button
onClick={() => { setSegCreate(c => !c); setSegPoints([]); setSegName('') }}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ml-2 ${
segCreate ? 'bg-green-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
+ Segment
</button>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Height:</span> <span className="text-xs text-gray-500">Route:</span>
{[['speed', 'Speed'], ['solid', 'Solid']].map(([mode, label]) => (
<button
key={mode}
onClick={() => setColorMode(mode)}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
colorMode === mode ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
{label}
</button>
))}
<span className="text-xs text-gray-500 ml-2">Height:</span>
{[280, 420, 560].map(h => ( {[280, 420, 560].map(h => (
<button <button
key={h} key={h}
@@ -156,6 +222,35 @@ export default function ActivityDetailPage() {
))} ))}
</div> </div>
</div> </div>
{segCreate && (
<div className="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-gray-800 bg-green-900/10 text-xs">
<span className="text-green-400">
Click two points on the route to mark the segment start and end.
</span>
<span className="text-gray-400">
Start: {segPoints[0] ? `${(segPoints[0].distance_m / 1000).toFixed(2)} km` : '—'}
{' · '}End: {segPoints[1] ? `${(segPoints[1].distance_m / 1000).toFixed(2)} km` : '—'}
</span>
{segPoints.length === 2 && (
<>
<input
value={segName}
onChange={e => setSegName(e.target.value)}
placeholder="Segment name"
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-white focus:outline-none focus:ring-2 focus:ring-green-500"
/>
<button onClick={createSegment} disabled={!segName.trim()}
className="bg-green-600 hover:bg-green-700 disabled:opacity-40 text-white px-3 py-1 rounded-lg">
Create
</button>
</>
)}
{segPoints.length > 0 && (
<button onClick={() => setSegPoints([])} className="text-gray-400 hover:text-white">Reset</button>
)}
{segError && <span className="text-red-400">{segError}</span>}
</div>
)}
<div style={{ height: mapHeight }}> <div style={{ height: mapHeight }}>
<ActivityMap <ActivityMap
polyline={activity.polyline} polyline={activity.polyline}
@@ -163,9 +258,23 @@ export default function ActivityDetailPage() {
hoveredDistance={hoveredDistance} hoveredDistance={hoveredDistance}
sportType={activity.sport_type} sportType={activity.sport_type}
mapType={mapType} mapType={mapType}
colorMode={colorMode}
onMapClick={segCreate ? handleMapClick : undefined}
/> />
</div> </div>
{colorMode === 'speed' && (
<div className="flex items-center gap-2 px-4 py-2 border-t border-gray-800">
<span className="text-xs text-gray-500">Slow</span>
<div className="h-2 flex-1 max-w-xs rounded-full" style={{ background: SPEED_GRADIENT }} />
<span className="text-xs text-gray-500">Fast</span>
</div> </div>
)}
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 flex items-center justify-center text-gray-600 text-sm">
No GPS track for this activity
</div>
)}
{/* Metric timeline */} {/* Metric timeline */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
@@ -201,51 +310,26 @@ export default function ActivityDetailPage() {
)} )}
</div> </div>
{/* Laps + Segments side by side */} {/* Laps · Routes · Segments — on one row, each shrinking to fit and
{((laps && laps.length > 0) || (segments && segments.length > 0 && dataPoints)) && ( expanding to fill the width when fewer are present. */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> {((laps && laps.length > 0) || (actSegments && actSegments.length > 0) || (routeBoard && routeBoard.top?.length > 0)) && (
<div className="flex flex-wrap gap-4 items-start">
{laps && laps.length > 0 && ( {laps && laps.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
<LapTable laps={laps} sportType={activity.sport_type} /> <LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
</div> </div>
)} )}
{segments && segments.length > 0 && dataPoints && ( {routeBoard && routeBoard.top?.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-3"> <h3 className="text-sm font-medium text-gray-300 mb-3">Route Top 10 Times</h3>
<h3 className="text-sm font-medium text-gray-300">Segments</h3> <RouteLeaderboard data={routeBoard} />
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage </Link>
</div>
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 mb-1">
<span className="flex-1 text-xs text-gray-600 uppercase tracking-wide">Segment</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">This run</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Best</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Δ</span>
</div>
<div className="space-y-0.5">
{segments.map(seg => {
const t = segmentTime(dataPoints, seg.start_distance_m, seg.end_distance_m)
const best = segmentBests?.find(b => b.segment_id === seg.id)
const isNewBest = t != null && best?.best_s != null && t <= best.best_s + 0.5
const delta = t != null && best?.best_s != null ? t - best.best_s : null
return (
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/40 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className={`font-mono text-xs w-14 text-right ${isNewBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{t != null ? formatDuration(t) : <span className="text-gray-700">--</span>}
</span>
<span className="font-mono text-xs w-14 text-right text-gray-500">
{best?.best_s != null ? formatDuration(best.best_s) : '--'}
</span>
<span className={`font-mono text-xs w-14 text-right ${
isNewBest ? 'text-yellow-400' : delta == null ? 'text-gray-700' : delta <= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{isNewBest ? '🏆' : delta == null ? '--' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
</span>
</div>
)
})}
</div> </div>
)}
{actSegments && actSegments.length > 0 && (
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
<SegmentsPanel segments={actSegments} activityId={Number(id)} />
</div> </div>
)} )}
</div> </div>
+169 -56
View File
@@ -1,69 +1,85 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts' import { useMemo } from 'react'
import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns' import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
import api from '../utils/api' import api from '../utils/api'
import StatCard from '../components/ui/StatCard' import StatCard from '../components/ui/StatCard'
import ActivityMap from '../components/activity/ActivityMap'
import { import {
formatDuration, formatDistance, formatPace, formatHeartRate, formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
formatDate, sportIcon, formatSleep, formatDate, sportIcon, formatSleep,
} from '../utils/format' } from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
function bbLevelColor(level) { const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
if (level == null) return '#6b7280'
if (level >= 75) return '#3b82f6' function Stat({ label, value }) {
if (level >= 50) return '#22c55e' return (
if (level >= 25) return '#f59e0b' <div className="bg-gray-900 px-4 py-3 flex flex-col justify-center">
return '#ef4444' <p className="text-xs text-gray-500">{label}</p>
<p className="text-lg font-semibold text-white">{value}</p>
</div>
)
} }
function MiniBodyBattery({ bb }) { function MiniBodyBattery({ bb, hires, sleepStart, sleepEnd }) {
if (!bb?.end_level && !bb?.charged) return null const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ ts, level }))
const { charged, drained, start_level, end_level, values } = bb const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
const color = bbLevelColor(end_level) const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
const sparkData = Array.isArray(values) // Same classification the Health page uses, so colours match across views.
? values.map(([ts, level]) => ({ ts, level })) const data = raw.map((d, i) => ({
: [] ...d,
type: inferBBType(d.ts, 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 (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full flex flex-col">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3> <h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
<Link to="/health" className="text-xs text-blue-400 hover:underline">View </Link> <Link to="/health" className="text-xs text-blue-400 hover:underline">View </Link>
</div> </div>
<div className="flex items-baseline gap-3 flex-wrap"> <div className="flex items-baseline gap-3 flex-wrap">
{end_level != null && ( {peak != null && (
<span className="text-3xl font-bold" style={{ color }}>{Math.round(end_level)}</span> <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>
)} )}
{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>
{start_level != null && end_level != null && ( {hasGraph ? (
<p className="text-xs text-gray-500 mt-1">{start_level} {end_level} today</p> <>
)} <div className="mt-3 flex-1">
{sparkData.length >= 2 && ( <ResponsiveContainer width="100%" height={70}>
<div className="mt-3"> <BarChart data={data} margin={{ top: 2, right: 0, bottom: 0, left: 0 }} barCategoryGap={0}>
<ResponsiveContainer width="100%" height={60}> <YAxis domain={[0, 100]} hide />
<AreaChart data={sparkData} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="bbGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area type="monotone" dataKey="level" stroke={color} strokeWidth={1.5}
fill="url(#bbGrad)" dot={false} isAnimationActive={false} />
<Tooltip <Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11 }} contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11, color: '#fff' }}
itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
labelFormatter={ts => format(new Date(ts), 'HH:mm')} labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={v => [`${Math.round(v)}`, 'Battery']} formatter={v => [`${Math.round(v)}%`, 'Battery']}
/> />
</AreaChart> <Bar dataKey="level" isAnimationActive={false} radius={0}>
{data.map((d, i) => <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />)}
</Bar>
</BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </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> </div>
) )
@@ -138,9 +154,37 @@ export default function DashboardPage() {
}).then(r => r.data), }).then(r => r.data),
}) })
const { data: healthSummary } = useQuery({ const { data: recentHealth } = useQuery({
queryKey: ['health-summary'], queryKey: ['health-metrics', 'dash'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data), queryFn: () => api.get('/health-metrics/', { params: { limit: 365 } }).then(r => r.data),
})
// Latest available (non-null) value per metric — Garmin updates some fields
// less often than daily, so "today" can be sparse.
const health = useMemo(() => {
const rows = [...(recentHealth || [])].sort((a, b) => new Date(b.date) - new Date(a.date))
const pick = f => rows.find(d => d[f] != null)?.[f] ?? null
return {
date: rows[0]?.date ? rows[0].date.slice(0, 10) : null, // intraday endpoint wants YYYY-MM-DD
resting_hr: pick('resting_hr'),
sleep_duration_s: pick('sleep_duration_s'),
// Sleep window must come from the SAME day as `date` (the day whose intraday
// body battery we chart), not the latest non-null — otherwise the sleep
// shading is aligned to a different night. Null here just means "no shading".
sleep_start: rows[0]?.sleep_start ?? null,
sleep_end: rows[0]?.sleep_end ?? null,
hrv_nightly_avg: pick('hrv_nightly_avg'),
sleep_score: pick('sleep_score'),
steps: pick('steps'),
vo2max: pick('vo2max'),
avg_stress: pick('avg_stress'),
}
}, [recentHealth])
const { data: intraday } = useQuery({
queryKey: ['health-intraday-dash', health.date],
queryFn: () => api.get('/health-metrics/intraday', { params: { date: health.date } }).then(r => r.data),
enabled: !!health.date,
}) })
const { data: records } = useQuery({ const { data: records } = useQuery({
@@ -153,7 +197,13 @@ export default function DashboardPage() {
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data), queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
}) })
const latest = healthSummary?.latest const featured = recentActivities?.[0]
const { data: featuredSegments } = useQuery({
queryKey: ['activity-segments', featured?.id],
queryFn: () => api.get(`/segments/by-activity/${featured.id}`).then(r => r.data),
enabled: !!featured?.id,
})
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@@ -162,11 +212,12 @@ export default function DashboardPage() {
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link> <Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
</div> </div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<StatCard label="Steps today" value={health.steps != null ? health.steps.toLocaleString() : '--'} accent="green" sub="goal 10,000" />
<StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" /> <StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
<StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" /> <StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" /> <StatCard label="Resting HR" value={formatHeartRate(health.resting_hr)} accent="red" />
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} /> <StatCard label="Sleep" value={formatSleep(health.sleep_duration_s)} />
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
@@ -176,19 +227,20 @@ export default function DashboardPage() {
</div> </div>
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<MiniBodyBattery bb={latest?.body_battery} /> <MiniBodyBattery bb={intraday?.body_battery} hires={intraday?.body_battery_hires}
sleepStart={health.sleep_start} sleepEnd={health.sleep_end} />
</div> </div>
<div className="lg:col-span-1 bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3"> <div className="lg:col-span-1 bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-300">Health today</h3> <h3 className="text-sm font-medium text-gray-300">Health today</h3>
{latest ? ( {health.date ? (
<> <>
{[ {[
['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'], ['HRV', health.hrv_nightly_avg ? `${Math.round(health.hrv_nightly_avg)} ms` : '--'],
['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'], ['Sleep score', health.sleep_score ? Math.round(health.sleep_score) : '--'],
['Steps', latest.steps?.toLocaleString() ?? '--'], ['Steps', health.steps?.toLocaleString() ?? '--'],
['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'], ['VO2 Max', health.vo2max ? health.vo2max.toFixed(1) : '--'],
['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'], ['Stress', health.avg_stress ? Math.round(health.avg_stress) : '--'],
].map(([label, val]) => ( ].map(([label, val]) => (
<div key={label} className="flex justify-between text-sm"> <div key={label} className="flex justify-between text-sm">
<span className="text-gray-500">{label}</span> <span className="text-gray-500">{label}</span>
@@ -203,6 +255,67 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Featured most-recent activity */}
{featured && (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl">{sportIcon(featured.sport_type)}</span>
<div className="min-w-0">
<Link to={`/activities/${featured.id}`} className="text-sm font-semibold text-white hover:text-blue-400 transition-colors truncate block">
{featured.name}
</Link>
<p className="text-xs text-gray-500">{formatDate(featured.start_time)}</p>
</div>
</div>
<Link to={`/activities/${featured.id}`} className="text-xs text-blue-400 hover:underline flex-shrink-0">Open </Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3">
<div className="lg:col-span-2 h-64 bg-gray-950">
{featured.polyline
? <ActivityMap polyline={featured.polyline} sportType={featured.sport_type} colorMode="solid" />
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
</div>
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50">
<Stat label="Distance" value={formatDistance(featured.distance_m)} />
<Stat label="Elevation ↑" value={formatElevation(featured.elevation_gain_m)} />
<Stat label="Moving time" value={formatDuration(featured.duration_s)} />
<Stat label="Calories" value={featured.calories ? `${Math.round(featured.calories)} kcal` : '--'} />
</div>
</div>
{featuredSegments?.length > 0 && (
<div className="border-t border-gray-800 px-4 py-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-medium text-gray-400 uppercase tracking-wide">Segments</h4>
<Link to={`/activities/${featured.id}`} className="text-xs text-blue-400 hover:underline">Details </Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5">
{featuredSegments.map(seg => {
const isPodium = seg.rank && seg.rank <= 3
const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null
return (
<div key={seg.segment_id} className="flex items-center gap-2 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className={`font-mono text-xs ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{formatDuration(seg.duration_s)}
</span>
<span className="w-8 text-right text-xs">
{isPodium
? <span title={`#${seg.rank} of ${seg.effort_count}`}>{MEDALS[seg.rank]}</span>
: delta != null
? <span className="text-red-400 font-mono">+{formatDuration(delta)}</span>
: <span className="text-gray-700">--</span>}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* Recent activities */} {/* Recent activities */}
<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-4"> <div className="flex items-center justify-between mb-4">
+193 -58
View File
@@ -1,12 +1,13 @@
import { useState, useMemo } 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, BarChart, Bar, ReferenceLine, AreaChart, Area, BarChart, Bar, ReferenceLine, ReferenceArea,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
} from 'recharts' } from 'recharts'
import { format, subDays } from 'date-fns' import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns'
import api from '../utils/api' import api from '../utils/api'
import { formatSleep, sportIcon } from '../utils/format' import { formatSleep, sportIcon } from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
const RANGES = [ const RANGES = [
{ label: '1W', days: 7 }, { label: '1W', days: 7 },
@@ -181,42 +182,11 @@ function IntradayHrChart({ values }) {
// ── Body Battery ───────────────────────────────────────────────────────────── // ── Body Battery ─────────────────────────────────────────────────────────────
const BB_INFERRED_COLOR = {
sleep: '#4f46e5',
rest: '#0d9488',
activity: '#f97316',
stable: '#374151',
}
const BB_INFERRED_LABEL = {
sleep: 'Sleep',
rest: 'Rest',
activity: 'Active/Stress',
stable: 'Stable',
}
function bbLevelColor(level) {
if (level == null) return '#6b7280'
if (level >= 75) return '#3b82f6'
if (level >= 50) return '#22c55e'
if (level >= 25) return '#f59e0b'
return '#ef4444'
}
function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
const inSleep = sleepStartMs != null && sleepEndMs != null && tsMs >= sleepStartMs && tsMs <= sleepEndMs
if (inSleep) return 'sleep'
if (prevLevel != null) {
if (level > prevLevel + 0.3) return 'rest'
if (level < prevLevel - 0.3) return 'activity'
}
return 'stable'
}
function ActivityRefLabel({ viewBox, icon }) { function ActivityRefLabel({ viewBox, icon }) {
if (!viewBox) return null if (!viewBox) return null
const { x, y } = viewBox const { x, y, width = 0 } = viewBox
return ( return (
<text x={x} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}> <text x={x + width / 2} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
{icon} {icon}
</text> </text>
) )
@@ -245,6 +215,14 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
const levelColor = bbLevelColor(end_level) const levelColor = bbLevelColor(end_level)
const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null 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>
@@ -272,23 +250,31 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
interval={Math.max(1, Math.floor(chartData.length / 6))} /> interval={Math.max(1, Math.floor(chartData.length / 6))} />
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28} <YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} /> tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} />
<Tooltip contentStyle={tooltipStyle} <Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
labelFormatter={ts => format(new Date(ts), 'HH:mm')} labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={v => [`${Math.round(v)}`, 'Battery']} /> formatter={v => [`${Math.round(v)}%`, 'Battery']} />
<Bar dataKey="level" isAnimationActive={false} radius={0}> <Bar dataKey="level" isAnimationActive={false} radius={0}>
{chartData.map((d, i) => ( {chartData.map((d, i) => (
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} /> <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
))} ))}
</Bar> </Bar>
{(activities || []).map(a => ( {(activities || []).map(a => {
<ReferenceLine const start = new Date(a.start_time).getTime()
key={a.id} const end = a.duration_s ? start + a.duration_s * 1000 : start
x={new Date(a.start_time).getTime()} 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)" stroke="rgba(255,255,255,0.3)"
strokeWidth={1.5} strokeWidth={1}
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />} label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
/> />
))} )
})}
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -425,7 +411,7 @@ function NavArrow({ onClick, disabled, children }) {
) )
} }
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) { function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
if (!day) return ( if (!day) return (
<div className="text-center py-10 text-gray-600"> <div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p> <p className="text-3xl mb-2">📊</p>
@@ -562,11 +548,14 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
<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-emerald-400">
{day.weight_kg ? day.weight_kg.toFixed(1) : '--'} {snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'}
</span> </span>
{day.weight_kg && <span className="text-xs text-gray-500">kg</span>} {snapshotWeight && <span className="text-xs text-gray-500">kg</span>}
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>} {snapshotWeight?.fat && !snapshotWeight.carried && <span className="text-xs text-gray-500">{snapshotWeight.fat.toFixed(1)}% fat</span>}
</div> </div>
{snapshotWeight?.carried && (
<p className="text-xs text-gray-600 mt-0.5">as of {format(new Date(snapshotWeight.date), 'd MMM')}</p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -716,11 +705,15 @@ function SleepChart({ data, selectedDate, onDayClick }) {
if (!hasData) return ( if (!hasData) return (
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div> <div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
) )
const totals = chartData
.map(d => (d.deep || 0) + (d.rem || 0) + (d.light || 0) + (d.awake || 0))
.filter(t => t > 0)
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={140}>
<BarChart <BarChart
data={chartData} data={chartData}
margin={{ top: 4, right: 4, bottom: 4, left: 0 }} margin={{ top: 4, right: 44, bottom: 4, left: 0 }}
barSize={6} barSize={6}
style={{ cursor: onDayClick ? 'pointer' : 'default' }} style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => { onClick={evt => {
@@ -737,6 +730,12 @@ function SleepChart({ data, selectedDate, onDayClick }) {
{selectedDate && ( {selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" /> <ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)} )}
<ReferenceLine y={8} stroke="#22c55e" strokeDasharray="4 3" strokeWidth={1.5}
label={{ value: '8h', position: 'right', fill: '#22c55e', fontSize: 9 }} />
{avgSleep != null && (
<ReferenceLine y={avgSleep} stroke="#a855f7" strokeDasharray="4 3" strokeWidth={1.5}
label={{ value: `avg ${avgSleep}h`, position: 'right', fill: '#a855f7', fontSize: 9 }} />
)}
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" /> <Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" /> <Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" /> <Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
@@ -746,6 +745,99 @@ function SleepChart({ data, selectedDate, onDayClick }) {
) )
} }
// ── Weight (with goal line + kg ⇄ st/lb toggle) ──────────────────────────────
const KG_TO_LB = 2.2046226218
function fmtStLb(lb) {
let st = Math.floor(lb / 14)
let r = Math.round(lb - st * 14)
if (r === 14) { st += 1; r = 0 }
return `${st} st ${r} lb`
}
function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
const [unit, setUnit] = useState(() => localStorage.getItem('weightUnit') || 'kg')
const choose = (u) => { setUnit(u); localStorage.setItem('weightUnit', u) }
const imperial = unit === 'lb'
const toU = (kg) => (imperial ? kg * KG_TO_LB : kg)
const withWeight = data.filter(d => d.weight_kg != null)
const series = withWeight.map(d => ({ date: d.date, w: +toU(d.weight_kg).toFixed(2) }))
const title = imperial ? 'Weight (st & lb)' : 'Weight (kg)'
const toggle = (
<div className="flex gap-1">
{[['kg', 'kg'], ['lb', 'st/lb']].map(([u, label]) => (
<button key={u} onClick={() => choose(u)}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
unit === u ? 'bg-blue-600 text-white' : 'text-gray-400 bg-gray-800 hover:text-white'
}`}>
{label}
</button>
))}
</div>
)
if (!series.length) {
return (
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
</div>
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No weight data</div>
</>
)
}
const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
const minKg = Math.min(...withWeight.map(d => d.weight_kg))
const goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null
const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent
const yMin = Math.max(0, Math.floor(toU(Math.max(0, minKg - 20)))) // lowest weight 20 kg equivalent
const fmtVal = (v) => (imperial ? `${fmtStLb(v)} (${Math.round(v)} lb)` : `${v.toFixed(1)} kg`)
return (
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
</div>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => {
const p = evt?.activePayload?.[0]?.payload
if (p?.date && onDayClick) onDayClick(p.date)
}}>
<defs>
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis domain={[yMin, yMax]} tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={36} tickFormatter={v => Math.round(v)} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [fmtVal(v), 'Weight']} />
{selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
{goalU != null && (
<ReferenceLine y={goalU} stroke="#22c55e" strokeDasharray="5 3" strokeWidth={1.5}
label={{ value: `Goal ${imperial ? fmtStLb(goalU) : `${goalU} kg`}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
)}
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
connectNulls isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</>
)
}
// ── Page ───────────────────────────────────────────────────────────────────── // ── Page ─────────────────────────────────────────────────────────────────────
export default function HealthPage() { export default function HealthPage() {
@@ -793,6 +885,18 @@ export default function HealthPage() {
[allDays], [allDays],
) )
// Disable trend ranges that reach further back than the data goes. Keep every
// range up to and including the first one that already covers the full history
// enabled; ranges beyond that would only show the same (full) data. While the
// history is still loading we leave all ranges enabled.
const maxEnabledRangeIdx = useMemo(() => {
if (!allDaysSorted.length) return RANGES.length - 1
const oldest = allDaysSorted[allDaysSorted.length - 1].date
const span = differenceInCalendarDays(new Date(), parseISO(oldest))
const idx = RANGES.findIndex(r => r.days >= span)
return idx === -1 ? RANGES.length - 1 : idx
}, [allDaysSorted])
const selectedDay = useMemo(() => { const selectedDay = useMemo(() => {
if (!selectedDateStr) return allDaysSorted[0] || null if (!selectedDateStr) return allDaysSorted[0] || null
return allDaysSorted.find(d => d.date === selectedDateStr) || null return allDaysSorted.find(d => d.date === selectedDateStr) || null
@@ -809,6 +913,15 @@ export default function HealthPage() {
return found ? found.vo2max : null return found ? found.vo2max : null
}, [allDaysSorted]) }, [allDaysSorted])
// Weight for the snapshot: the selected day's, or the most recent earlier reading.
const snapshotWeight = useMemo(() => {
if (!selectedDay) return null
if (selectedDay.weight_kg != null)
return { kg: selectedDay.weight_kg, fat: selectedDay.body_fat_pct, carried: false }
const earlier = allDaysSorted.find(d => d.weight_kg != null && d.date <= selectedDay.date)
return earlier ? { kg: earlier.weight_kg, fat: earlier.body_fat_pct, carried: true, date: earlier.date } : null
}, [selectedDay, allDaysSorted])
const { data: intradayData } = useQuery({ const { data: intradayData } = useQuery({
queryKey: ['health-intraday', selectedDay?.date], queryKey: ['health-intraday', selectedDay?.date],
queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data), queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data),
@@ -844,6 +957,7 @@ export default function HealthPage() {
<DailySnapshot <DailySnapshot
day={selectedDay} day={selectedDay}
snapshotWeight={snapshotWeight}
avg30={summary?.avg_30d} avg30={summary?.avg_30d}
intradayHr={intradayData?.hr_values} intradayHr={intradayData?.hr_values}
bodyBattery={intradayData?.body_battery} bodyBattery={intradayData?.body_battery}
@@ -868,16 +982,24 @@ export default function HealthPage() {
<p className="text-xs text-gray-600">Click any point to load that day above</p> <p className="text-xs text-gray-600">Click any point to load that day above</p>
</div> </div>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{RANGES.map(({ label, days }) => ( {RANGES.map(({ label, days }, i) => {
<button key={label} onClick={() => setRangeDays(days)} const disabled = i > maxEnabledRangeIdx
return (
<button key={label}
onClick={() => !disabled && setRangeDays(days)}
disabled={disabled}
title={disabled ? 'Not enough history for this range' : undefined}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${ className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
rangeDays === days disabled
? 'border-gray-800 text-gray-700 opacity-40 cursor-not-allowed'
: rangeDays === days
? 'bg-blue-600 border-blue-600 text-white' ? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white' : 'border-gray-700 text-gray-400 hover:text-white'
}`}> }`}>
{label} {label}
</button> </button>
))} )
})}
</div> </div>
</div> </div>
@@ -920,14 +1042,26 @@ export default function HealthPage() {
</div> </div>
</div> </div>
{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">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Score</h3>
<MetricChart <MetricChart data={metrics} dataKey="sleep_score" color="#818cf8"
data={metrics.filter(d => d.weight_kg != null)} formatter={v => Math.round(v)}
dataKey="weight_kg" color="#34d399" domain={[0, 100]}
formatter={v => `${v.toFixed(1)} kg`} connectNulls showDots
selectedDate={selDateForCharts} onDayClick={handleDayClick} selectedDate={selDateForCharts} onDayClick={handleDayClick}
connectNulls showDots /> referenceLines={[
{ y: 80, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
]}
/>
</div>
)}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<WeightChart
data={metrics}
goalKg={profile?.goal_weight_kg}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</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">
@@ -989,6 +1123,7 @@ export default function HealthPage() {
<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="#3b82f6"
formatter={v => v.toFixed(1)} formatter={v => v.toFixed(1)}
domain={[30, 70]}
connectNulls showDots connectNulls showDots
selectedDate={selDateForCharts} onDayClick={handleDayClick} /> selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div> </div>
+9 -5
View File
@@ -4,15 +4,19 @@ import { useAuthStore } from '../hooks/useAuth'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import api from '../utils/api' import api from '../utils/api'
const AUTH_ERRORS = {
not_authorized: "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group.",
passkey_in_use: "That passkey is already linked to another account. Sign in to that account, or have an admin remove it on the Users page, then try linking again.",
link_failed: "Couldn't link the passkey. Please try again.",
invalid_state: "Your sign-in link expired or was invalid. Please try signing in again.",
no_identity: "Couldn't read your identity from the provider. Please try again.",
}
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const authError = new URLSearchParams(window.location.search).get('auth_error') const authError = new URLSearchParams(window.location.search).get('auth_error')
const [error, setError] = useState( const [error, setError] = useState(AUTH_ERRORS[authError] || '')
authError === 'not_authorized'
? "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group."
: ''
)
const { login, isLoading } = useAuthStore() const { login, isLoading } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
+80 -87
View File
@@ -1,7 +1,18 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef } from 'react'
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 { useAuthStore } from '../hooks/useAuth' import { useAuthStore } from '../hooks/useAuth'
import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync'
// Human-friendly description of the automatic sync cadence, e.g. "every 30 min",
// "hourly", "every 2 h". Driven by the backend's configured interval.
function formatSyncInterval(minutes) {
if (!minutes || minutes <= 0) return 'automatic'
if (minutes === 60) return 'hourly'
if (minutes < 60) return `every ${minutes} min`
if (minutes % 60 === 0) return `every ${minutes / 60} h`
return `every ${Math.floor(minutes / 60)} h ${minutes % 60} min`
}
function Section({ title, children }) { function Section({ title, children }) {
return ( return (
@@ -43,38 +54,47 @@ function SaveButton({ onClick, loading, saved, label = 'Save' }) {
export default function ProfilePage() { export default function ProfilePage() {
const qc = useQueryClient() const qc = useQueryClient()
const { user } = useAuthStore() const { user, fetchUser } = useAuthStore()
const { data: profile } = useQuery({ const { data: profile } = useQuery({
queryKey: ['profile'], queryKey: ['profile'],
queryFn: () => api.get('/profile/').then(r => r.data), queryFn: () => api.get('/profile/').then(r => r.data),
}) })
// Passkey linking (available to all users when PocketID is configured)
const { data: pocketidAvailable } = useQuery({
queryKey: ['pocketid-available'],
queryFn: () => api.get('/auth/pocketid/available').then(r => r.data),
})
const [showLinked, setShowLinked] = useState(
new URLSearchParams(window.location.search).get('linked') === '1'
)
useEffect(() => {
if (showLinked) {
fetchUser() // refresh has_passkey
window.history.replaceState({}, '', '/profile')
const t = setTimeout(() => setShowLinked(false), 6000)
return () => clearTimeout(t)
}
}, [])
const handleLinkPasskey = async () => {
const { data } = await api.get('/auth/pocketid/link-url')
window.location.href = data.url
}
const { data: pocketidConfig } = useQuery({ const { data: pocketidConfig } = useQuery({
queryKey: ['pocketid-config'], queryKey: ['pocketid-config'],
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data), queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
enabled: !!user?.is_admin, enabled: !!user?.is_admin,
}) })
const { data: recentMetrics } = useQuery({
queryKey: ['health-metrics-recent'],
queryFn: () => api.get('/health-metrics/', { params: { limit: 7 } }).then(r => r.data),
})
const { data: healthSummary } = useQuery({ const { data: healthSummary } = useQuery({
queryKey: ['health-summary'], queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data), queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
}) })
const avgRestingHr = useMemo(() => {
if (!recentMetrics?.length) return null
const vals = recentMetrics.filter(m => m.resting_hr != null).map(m => m.resting_hr)
if (!vals.length) return null
return Math.round(vals.reduce((s, v) => s + v, 0) / vals.length)
}, [recentMetrics])
// HR / measurements form // HR / measurements form
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '' }) const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '', goal_weight_kg: '' })
const [hrSaved, setHrSaved] = useState(false) const [hrSaved, setHrSaved] = useState(false)
const [hrZoneRecalc, setHrZoneRecalc] = useState(false) const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
const maxHrChangedRef = useRef(false) const maxHrChangedRef = useRef(false)
@@ -84,6 +104,7 @@ export default function ProfilePage() {
birth_year: profile.birth_year || '', birth_year: profile.birth_year || '',
height_cm: profile.height_cm || '', height_cm: profile.height_cm || '',
biological_sex: profile.biological_sex || '', biological_sex: profile.biological_sex || '',
goal_weight_kg: profile.goal_weight_kg || '',
}) })
}, [profile]) }, [profile])
@@ -119,10 +140,8 @@ export default function ProfilePage() {
const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' }) const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
const [gcSaved, setGcSaved] = useState(false) const [gcSaved, setGcSaved] = useState(false)
const [gcError, setGcError] = useState('') const [gcError, setGcError] = useState('')
const [gcSyncing, setGcSyncing] = useState(false) const { inProgress: gcSyncing, status: syncStatus, trigger: triggerSync, cancel: cancelSync } = useSyncStore()
const syncPollRef = useRef(null)
const gcFormLoaded = useRef(false) const gcFormLoaded = useRef(false)
useEffect(() => () => { if (syncPollRef.current) clearInterval(syncPollRef.current) }, [])
useEffect(() => { useEffect(() => {
if (garminConfig?.connected && !gcFormLoaded.current) { if (garminConfig?.connected && !gcFormLoaded.current) {
gcFormLoaded.current = true gcFormLoaded.current = true
@@ -156,54 +175,6 @@ 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' })
}, },
}) })
const triggerGarminSync = async () => {
setGcSyncing(true)
try {
await api.post('/garmin-sync/trigger')
// Poll every 3s: wait until we've seen an in-progress status, then wait for terminal
let seenInProgress = false
syncPollRef.current = setInterval(async () => {
const result = await refetchGarmin()
const status = result.data?.last_sync_status ?? ''
const terminal = status.startsWith('OK') || status.startsWith('Partial') || status.startsWith('Auth error')
if (!terminal) seenInProgress = true
if (seenInProgress && terminal) {
clearInterval(syncPollRef.current)
syncPollRef.current = null
setGcSyncing(false)
}
}, 3000)
// Absolute safety: stop polling after 4 hours but keep bar visible — sync may still be running
setTimeout(() => {
if (syncPollRef.current) { clearInterval(syncPollRef.current); syncPollRef.current = null }
}, 4 * 60 * 60 * 1000)
} catch {
setGcSyncing(false)
}
}
const syncProgressPct = status => {
if (!status) return 3
if (status.startsWith('Connecting')) return 10
if (status.startsWith('Syncing activities')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m) {
const done = parseInt(m[1], 10), total = parseInt(m[2], 10)
if (total > 0) return 15 + Math.round(done / total * 30)
}
return 20
}
if (status.startsWith('Syncing wellness')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m) {
const done = parseInt(m[1], 10), total = parseInt(m[2], 10)
if (total > 0) return 45 + Math.round(done / total * 45)
}
return 50
}
return 3
}
// 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)
@@ -264,22 +235,18 @@ export default function ProfilePage() {
</Field> </Field>
</div> </div>
{(avgRestingHr || healthSummary?.latest?.weight_kg) && ( <div className="grid grid-cols-2 gap-4 pt-3 border-t border-gray-800">
<div className="flex gap-6 pt-3 border-t border-gray-800"> <Field label="Goal weight (kg)" hint="Shown as a target line on the weight trend chart">
{avgRestingHr && ( <Input type="number" value={hrForm.goal_weight_kg} placeholder="e.g. 72" min={20} max={500}
<div> onChange={e => setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} />
<p className="text-xs text-gray-500 mb-0.5">Resting HR (7-day avg, from Garmin)</p> </Field>
<span className="text-lg font-semibold text-rose-400">{avgRestingHr} bpm</span>
</div>
)}
{healthSummary?.latest?.weight_kg && ( {healthSummary?.latest?.weight_kg && (
<div> <div>
<p className="text-xs text-gray-500 mb-0.5">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-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
</div> </div>
)} )}
</div> </div>
)}
<SaveButton <SaveButton
onClick={() => { onClick={() => {
@@ -325,11 +292,32 @@ export default function ProfilePage() {
/> />
</Section> </Section>
{/* Passkey sign-in — available to all users when PocketID is configured */}
{pocketidAvailable?.available && (
<Section title="🔑 Passkey sign-in">
{user?.has_passkey ? (
<p className="text-sm text-green-400"> A passkey is linked to this account you can sign in with PocketID.</p>
) : (
<>
<p className="text-xs text-gray-500">
Link your PocketID passkey to <strong>this</strong> account so you can sign in with a passkey
instead of being given a separate, empty account.
</p>
<button onClick={handleLinkPasskey}
className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2">
🔑 Link a passkey to this account
</button>
</>
)}
{showLinked && <p className="text-green-400 text-sm"> Passkey linked to your account.</p>}
</Section>
)}
{/* Garmin Connect Sync */} {/* Garmin Connect Sync */}
<Section title="⌚ Garmin Connect Sync"> <Section title="⌚ Garmin Connect Sync">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Connect your Garmin account to automatically import new activities and wellness data every hour. Connect your Garmin account to automatically import new activities and wellness data
Credentials are encrypted at rest. {' '}{formatSyncInterval(garminConfig?.sync_interval_minutes)}. Credentials are encrypted at rest.
</p> </p>
{garminConfig?.connected && ( {garminConfig?.connected && (
@@ -362,7 +350,7 @@ export default function ProfilePage() {
<div className="flex flex-wrap gap-4 pt-1"> <div className="flex flex-wrap gap-4 pt-1">
{[ {[
['sync_enabled', 'Enable hourly sync'], ['sync_enabled', `Enable automatic sync (${formatSyncInterval(garminConfig?.sync_interval_minutes)})`],
['sync_activities', 'Sync activities (FIT download)'], ['sync_activities', 'Sync activities (FIT download)'],
['sync_wellness', 'Sync wellness data'], ['sync_wellness', 'Sync wellness data'],
].map(([key, label]) => ( ].map(([key, label]) => (
@@ -375,7 +363,7 @@ export default function ProfilePage() {
))} ))}
</div> </div>
<Field label="Sync lookback days" hint="-1 syncs all available history (back to 2010). Leave at 30 for incremental syncs."> <Field label="Sync lookback days" hint="How far back to pull on the first sync (-1 = all history back to 2010). After that, scheduled syncs only refresh the last day or two. Increasing this value triggers a one-time backfill of the extra history on the next sync.">
<Input type="number" value={gcForm.sync_lookback_days} min={-1} <Input type="number" value={gcForm.sync_lookback_days} min={-1}
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} /> onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
{(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && ( {(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && (
@@ -407,7 +395,7 @@ export default function ProfilePage() {
{garminConfig?.connected && ( {garminConfig?.connected && (
<> <>
<button <button
onClick={triggerGarminSync} onClick={triggerSync}
disabled={gcSyncing} disabled={gcSyncing}
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"> 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">
{gcSyncing ? 'Syncing…' : '↻ Sync now'} {gcSyncing ? 'Syncing…' : '↻ Sync now'}
@@ -422,12 +410,9 @@ export default function ProfilePage() {
</div> </div>
{gcSyncing && (() => { {gcSyncing && (() => {
const status = garminConfig?.last_sync_status || '' const status = syncStatus || ''
const pct = syncProgressPct(status) const pct = syncProgressPct(status)
const phase = status.startsWith('Connecting') ? 0 const phase = syncPhase(status)
: status.startsWith('Syncing activities') ? 1
: status.startsWith('Syncing wellness') ? 2
: status.startsWith('OK') || status.startsWith('Partial') ? 3 : -1
return ( return (
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
<div className="flex items-center gap-1 text-xs"> <div className="flex items-center gap-1 text-xs">
@@ -438,12 +423,20 @@ export default function ProfilePage() {
</span> </span>
))} ))}
</div> </div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden"> <div className="flex items-center gap-2">
<div className="h-2 flex-1 bg-gray-800 rounded-full overflow-hidden">
<div <div
className="h-full bg-blue-500 rounded-full transition-all duration-700" className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<button
onClick={cancelSync}
disabled={status.startsWith('Cancel')}
className="text-red-400 hover:text-red-300 disabled:opacity-50 text-xs font-medium px-2 py-1 rounded-lg border border-red-500/40 hover:border-red-400 transition-colors whitespace-nowrap">
Cancel
</button>
</div>
<p className="text-xs text-blue-400"> <p className="text-xs text-blue-400">
{status || 'Starting sync…'} {status || 'Starting sync…'}
</p> </p>
+72 -92
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, Fragment } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
@@ -14,7 +14,9 @@ const DISTANCE_ORDER = [
'Half marathon', 'Marathon', '50k', '100k', 'Half marathon', 'Marathon', '50k', '100k',
] ]
const TABS = ['Distance PRs', 'Route Records', 'Segment Records'] const TABS = ['Distance PRs', 'Route Records', 'Segments']
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
function DistancePRs() { function DistancePRs() {
const [sport, setSport] = useState('running') const [sport, setSport] = useState('running')
@@ -122,7 +124,7 @@ function DistancePRs() {
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} <XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM yy')} /> tickFormatter={d => format(new Date(d), 'MMM yy')} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} <YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={40} tickFormatter={formatDuration} reversed /> width={40} tickFormatter={formatDuration} />
<Tooltip <Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }} contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
@@ -212,116 +214,94 @@ function RouteRecords() {
) )
} }
function SegmentLeaderboard({ segmentId }) {
const { data } = useQuery({
queryKey: ['segment', segmentId],
queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data),
})
if (!data) return <p className="text-xs text-gray-600 py-2 px-4">Loading</p>
if (!data.leaderboard?.length) return <p className="text-xs text-gray-600 py-2 px-4">No efforts yet still matching.</p>
return (
<div className="px-4 py-2 space-y-0.5 bg-gray-950/40">
{data.leaderboard.map((e, i) => (
<div key={e.activity_id} className="flex items-center gap-2 text-xs">
<span className="w-6 text-right">{MEDALS[e.rank] || i + 1}</span>
<span className="font-mono text-gray-200 w-16 text-right">{formatDuration(e.duration_s)}</span>
<Link to={`/activities/${e.activity_id}`} className="text-gray-400 hover:text-blue-400 truncate flex-1">
{e.activity_name}
</Link>
{e.date && <span className="text-gray-600">{formatDate(e.date)}</span>}
</div>
))}
</div>
)
}
function SegmentRecords() { function SegmentRecords() {
const [selectedRouteId, setSelectedRouteId] = useState(null) const [open, setOpen] = useState(null)
const { data: segments, isLoading } = useQuery({
const { data: routes } = useQuery({ queryKey: ['segments'],
queryKey: ['routes'], queryFn: () => api.get('/segments/').then(r => r.data),
queryFn: () => api.get('/routes/').then(r => r.data),
}) })
const { data: bests, isLoading } = useQuery({ if (isLoading) return <p className="text-gray-500 text-sm">Loading</p>
queryKey: ['segment-bests', selectedRouteId],
queryFn: () => api.get(`/routes/${selectedRouteId}/segment-bests`).then(r => r.data),
enabled: !!selectedRouteId,
})
const kmBests = (bests || []).filter(b => b.name?.startsWith('km ')) if (!segments?.length) return (
const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null) <div className="text-center py-16 text-gray-600">
? kmBests.reduce((sum, b) => sum + b.best_s, 0) <p className="text-4xl mb-3">🏅</p>
: null <p>No segments yet create one from an activity's detail page</p>
</div>
if (!routes?.length) return (
<p className="text-sm text-gray-600">
No named routes yet.{' '}
<Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link>
</p>
) )
return ( return (
<div className="space-y-4">
{/* Route tile grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{routes.map(r => (
<button
key={r.id}
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
className={`text-left rounded-xl border p-2 transition-colors ${
selectedRouteId === r.id
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
}`}
>
<RouteMiniMap
polyline={r.reference_polyline}
sportType={r.sport_type}
width="100%"
height={80}
/>
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
{r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)}
</button>
))}
</div>
{selectedRouteId && (
isLoading ? (
<p className="text-gray-500 text-sm">Loading</p>
) : !bests?.length ? (
<p className="text-gray-600 text-sm">
No segments for this route.{' '}
<Link to="/segments" className="text-blue-400 hover:underline">Create some on the Segments page.</Link>
</p>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"> <div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80"> <tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
<th className="text-left px-4 py-3 font-medium">Segment</th> <th className="px-3 py-3" />
<th className="text-right px-4 py-3 font-medium">Length</th> <th className="text-left px-3 py-3 font-medium">Segment</th>
<th className="text-right px-4 py-3 font-medium">Best time</th> <th className="text-right px-3 py-3 font-medium">Distance</th>
<th className="text-right px-4 py-3 font-medium">Runs</th> <th className="text-right px-3 py-3 font-medium">Best time</th>
<th className="px-4 py-3" /> <th className="text-right px-3 py-3 font-medium">Efforts</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{bests.map(b => ( {segments.map(seg => (
<tr key={b.segment_id} className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors"> <Fragment key={seg.id}>
<td className="px-4 py-3 text-gray-200"> <tr
{b.name} onClick={() => setOpen(open === seg.id ? null : seg.id)}
{b.auto_generated && <span className="ml-2 text-xs text-gray-600">(auto)</span>} className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
open === seg.id ? 'bg-blue-900/20' : 'hover:bg-gray-800/40'
}`}
>
<td className="px-3 py-2">
<RouteMiniMap polyline={seg.polyline} sportType={seg.sport_type} width={72} height={50} />
</td> </td>
<td className="px-4 py-3 text-right text-gray-500 text-xs"> <td className="px-3 py-3 font-medium text-white">
{formatDistance(b.end_distance_m - b.start_distance_m)} {seg.sport_type && <span className="capitalize text-xs text-gray-500 mr-2">{seg.sport_type}</span>}
{seg.name}
</td> </td>
<td className="px-4 py-3 text-right font-mono font-semibold"> <td className="px-3 py-3 text-right text-gray-400 text-xs">
{b.best_s != null {formatDistance(seg.distance_m)}
? <span className="text-yellow-400">{formatDuration(b.best_s)}</span>
: <span className="text-gray-700">--</span>}
</td> </td>
<td className="px-4 py-3 text-right text-gray-500 text-xs">{b.count}</td> <td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
<td className="px-4 py-3 text-right"> {seg.best_s != null ? formatDuration(seg.best_s) : '--'}
{b.best_activity_id && ( </td>
<Link to={`/activities/${b.best_activity_id}`} className="text-xs text-blue-400 hover:underline"> <td className="px-3 py-3 text-right text-gray-400 text-xs">
View {seg.effort_count}
</Link>
)}
</td> </td>
</tr> </tr>
{open === seg.id && (
<tr>
<td colSpan={5} className="p-0 border-b border-gray-800/50">
<SegmentLeaderboard segmentId={seg.id} />
</td>
</tr>
)}
</Fragment>
))} ))}
</tbody> </tbody>
</table> </table>
{theoreticalBest != null && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800 bg-gray-900/60">
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
<span className="font-mono text-sm font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
</div>
)
)}
</div> </div>
) )
} }
@@ -351,7 +331,7 @@ export default function RecordsPage() {
{tab === 'Distance PRs' && <DistancePRs />} {tab === 'Distance PRs' && <DistancePRs />}
{tab === 'Route Records' && <RouteRecords />} {tab === 'Route Records' && <RouteRecords />}
{tab === 'Segment Records' && <SegmentRecords />} {tab === 'Segments' && <SegmentRecords />}
</div> </div>
) )
} }
+179 -223
View File
@@ -2,92 +2,9 @@ import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } 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 { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format' import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
function formatSegDist(m) {
if (m == null) return '--'
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
}
function SegmentsPanel({ routeId, sportType }) {
const qc = useQueryClient()
const { data: segments } = useQuery({
queryKey: ['segments', routeId],
queryFn: () => api.get(`/routes/${routeId}/segments`).then(r => r.data),
})
const { data: bests } = useQuery({
queryKey: ['segment-bests', routeId],
queryFn: () => api.get(`/routes/${routeId}/segment-bests`).then(r => r.data),
})
const deleteSeg = useMutation({
mutationFn: segId => api.delete(`/routes/${routeId}/segments/${segId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['segments', routeId] })
qc.invalidateQueries({ queryKey: ['segment-bests', routeId] })
},
})
if (!segments?.length) return null
const bestMap = Object.fromEntries((bests || []).map(b => [b.segment_id, b]))
const kmSplits = segments.filter(s => s.name.startsWith('km '))
const hillsTurns = segments.filter(s => !s.name.startsWith('km '))
const kmBests = (bests || []).filter(b => b.name?.startsWith('km '))
const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null)
? kmBests.reduce((sum, b) => sum + b.best_s, 0)
: null
const renderGroup = (group, title) => {
if (!group.length) return null
return (
<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{title}</p>
{group.map(seg => {
const best = bestMap[seg.id]
return (
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/50 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className="text-gray-600 text-xs">{formatSegDist(seg.end_distance_m - seg.start_distance_m)}</span>
{best?.best_s != null ? (
<span className="font-mono text-yellow-400 text-xs w-14 text-right">{formatDuration(best.best_s)}</span>
) : (
<span className="text-gray-700 text-xs w-14 text-right">--</span>
)}
<button
onClick={() => { if (confirm(`Delete "${seg.name}"?`)) deleteSeg.mutate(seg.id) }}
className="text-gray-700 hover:text-red-400 transition-colors text-xs ml-1"
title="Delete segment"
></button>
</div>
)
})}
</div>
)
}
return (
<div className="border-t border-gray-800 pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-400">Segments</h3>
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage </Link>
</div>
{renderGroup(kmSplits, '1km Splits')}
{renderGroup(hillsTurns, 'Hills & Turns')}
{theoreticalBest != null && (
<div className="flex items-center justify-between pt-1 border-t border-gray-800/50">
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
</div>
)
}
// Decode Google encoded polyline to [[lat,lng], ...] // Decode Google encoded polyline to [[lat,lng], ...]
function decodePolyline(encoded) { function decodePolyline(encoded) {
if (!encoded) return [] if (!encoded) return []
@@ -134,47 +51,37 @@ function RouteMap({ polyline, className = '', sportType = '' }) {
function routeSportStyle(sportType) { function routeSportStyle(sportType) {
const t = (sportType || '').toLowerCase() const t = (sportType || '').toLowerCase()
if (t.includes('cycl') || t.includes('bike') || t.includes('ride')) 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', color: '#f97316' } 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')) 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', color: '#3b82f6' } 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', color: '#6b7280' } return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400' }
} }
export default function RoutesPage() { const MEDALS = ['🥇', '🥈', '🥉']
const [selected, setSelected] = useState(null)
const [showCreate, setShowCreate] = useState(false) function RouteDetail({ selected, setSelected }) {
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' }) const qc = useQueryClient()
const [merging, setMerging] = useState(false) const [merging, setMerging] = useState(false)
const [mergeTarget, setMergeTarget] = useState('') const [mergeTarget, setMergeTarget] = useState('')
const qc = useQueryClient() const [editingName, setEditingName] = useState(false)
const [nameInput, setNameInput] = useState(selected.name)
const { data: routes } = useQuery({ const { data: routes } = useQuery({
queryKey: ['routes'], queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data), queryFn: () => api.get('/routes/').then(r => r.data),
}) })
// Sort by most completions first
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
const { data: routeActivities } = useQuery({ const { data: routeActivities } = useQuery({
queryKey: ['route-activities', selected?.id], queryKey: ['route-activities', selected.id],
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data), queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
enabled: !!selected,
}) })
const { data: recentActivities } = useQuery({ const renameRoute = useMutation({
queryKey: ['recent-activities-for-route'], mutationFn: name => api.patch(`/routes/${selected.id}`, { name }).then(r => r.data),
queryFn: () => api.get('/routes/recent-activities').then(r => r.data), onSuccess: updated => {
enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: data => api.post('/routes/', data).then(r => r.data),
onSuccess: route => {
qc.invalidateQueries({ queryKey: ['routes'] }) qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false) setSelected(updated)
setNewRoute({ name: '', activity_id: '' }) setEditingName(false)
setSelected(route)
}, },
}) })
@@ -198,7 +105,161 @@ export default function RoutesPage() {
}) })
const fastest = routeActivities?.[0] const fastest = routeActivities?.[0]
const otherRoutes = routes?.filter(r => r.id !== selected?.id && r.sport_type === selected?.sport_type) ?? [] const crTime = fastest?.duration_s
const otherRoutes = (routes || []).filter(r => r.id !== selected.id && r.sport_type === selected.sport_type)
return (
<div className="col-span-full bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex gap-4 items-start min-w-0">
<div className="w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
{selected.reference_polyline
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
: <RouteMap polyline={selected.reference_polyline} className="w-full h-full" sportType={selected.sport_type} />}
</div>
<div className="min-w-0">
{editingName ? (
<div className="flex items-center gap-2">
<input
value={nameInput}
onChange={e => setNameInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && nameInput.trim()) renameRoute.mutate(nameInput.trim()) }}
autoFocus
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button onClick={() => renameRoute.mutate(nameInput.trim())} disabled={!nameInput.trim() || renameRoute.isPending}
className="text-xs bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white px-2 py-1 rounded-lg">Save</button>
<button onClick={() => { setEditingName(false); setNameInput(selected.name) }}
className="text-xs text-gray-400 hover:text-white px-1">Cancel</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-white truncate">{selected.name}</h2>
<button onClick={() => { setNameInput(selected.name); setEditingName(true) }}
className="text-gray-500 hover:text-white text-sm" title="Rename route"></button>
</div>
)}
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
<span>{formatDistance(selected.distance_m)}</span>
{selected.auto_detected && (
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
Merge
</button>
<button
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
Delete
</button>
</div>
</div>
{/* Merge panel */}
{merging && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
<div className="flex gap-2">
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
<option value="">Select route to merge in</option>
{otherRoutes.map(r => (
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
))}
</select>
<button
disabled={!mergeTarget || mergeRoute.isPending}
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Merge
</button>
</div>
{otherRoutes.length === 0 && (
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
)}
</div>
)}
{/* Podium */}
{routeActivities?.length > 0 && (
<div className="grid grid-cols-3 gap-3">
{routeActivities.slice(0, 3).map((act, i) => (
<Link key={act.id} to={`/activities/${act.id}`}
className="bg-gray-800/50 hover:bg-gray-800 rounded-lg p-3 text-center transition-colors">
<p className="text-xl">{MEDALS[i]}</p>
<p className="font-mono text-lg font-bold text-white">{formatDuration(act.duration_s)}</p>
<p className="text-xs text-gray-500">{formatDate(act.start_time)}</p>
{i > 0 && crTime != null && (
<p className="text-xs text-red-400">+{formatDuration(act.duration_s - crTime)}</p>
)}
</Link>
))}
</div>
)}
{/* All completions */}
<h3 className="text-sm font-medium text-gray-400">All completions ({routeActivities?.length ?? 0})</h3>
<div className="space-y-1">
{routeActivities?.map((act, i) => {
const delta = crTime != null ? act.duration_s - crTime : null
return (
<Link key={act.id} to={`/activities/${act.id}`}
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className={`font-mono text-xs w-16 text-right ${i === 0 ? 'text-yellow-400' : 'text-red-400'}`}>
{i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''}
</span>
<span className="text-gray-500 w-20 text-right">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate
? <span className="text-red-400 text-xs w-16 text-right">{Math.round(act.avg_heart_rate)} bpm</span>
: <span className="w-16" />}
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors"></span>
</Link>
)
})}
</div>
</div>
)
}
export default function RoutesPage() {
const [selected, setSelected] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
const qc = useQueryClient()
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
// Sort by most completions first
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
const { data: recentActivities } = useQuery({
queryKey: ['recent-activities-for-route'],
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: data => api.post('/routes/', data).then(r => r.data),
onSuccess: route => {
qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false)
setNewRoute({ name: '', activity_id: '' })
setSelected(route)
},
})
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@@ -256,7 +317,7 @@ export default function RoutesPage() {
</div> </div>
)} )}
{/* Route tile grid */} {/* Route tile grid — selected route's detail expands inline under its row */}
{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>
@@ -268,9 +329,9 @@ export default function RoutesPage() {
{sortedRoutes.map(route => { {sortedRoutes.map(route => {
const style = routeSportStyle(route.sport_type) const style = routeSportStyle(route.sport_type)
const isSelected = selected?.id === route.id const isSelected = selected?.id === route.id
return ( return [
<button key={route.id} <button key={route.id}
onClick={() => { setSelected(isSelected ? null : route); setMerging(false) }} onClick={() => setSelected(isSelected ? null : route)}
className={`text-left rounded-xl border p-2 transition-all ${ className={`text-left rounded-xl border p-2 transition-all ${
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600` isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
}`}> }`}>
@@ -279,121 +340,16 @@ export default function RoutesPage() {
<div className="flex items-center justify-between mt-0.5 gap-1"> <div className="flex items-center justify-between mt-0.5 gap-1">
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span> <span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
{route.activity_count > 0 && ( {route.activity_count > 0 && (
<span className={`text-xs font-medium ${style.accent}`}> <span className={`text-xs font-medium ${style.accent}`}>{route.activity_count}×</span>
{route.activity_count}×
</span>
)} )}
</div> </div>
{route.auto_detected && ( {route.auto_detected && <span className="text-xs text-gray-600">auto</span>}
<span className="text-xs text-gray-600">auto</span> </button>,
)} isSelected && <RouteDetail key={`detail-${route.id}`} selected={selected} setSelected={setSelected} />,
</button> ]
)
})} })}
</div> </div>
)} )}
{/* Route detail — shown below the tile grid when a route is selected */}
{selected && (
<div className="space-y-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex gap-4 items-start">
<RouteMap polyline={selected.reference_polyline} className="w-36 h-24 flex-shrink-0" sportType={selected.sport_type} />
<div>
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
<span>{formatDistance(selected.distance_m)}</span>
{selected.auto_detected && (
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
Merge
</button>
<button
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
Delete
</button>
</div>
</div>
{/* Merge panel */}
{merging && (
<div className="mb-4 bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
<div className="flex gap-2">
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
<option value="">Select route to merge in</option>
{otherRoutes.map(r => (
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
))}
</select>
<button
disabled={!mergeTarget || mergeRoute.isPending}
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Merge
</button>
<button onClick={() => setMerging(false)}
className="text-gray-400 hover:text-white text-sm px-3 py-2 rounded-lg transition-colors">
Cancel
</button>
</div>
{otherRoutes.length === 0 && (
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
)}
</div>
)}
{/* Course record */}
{fastest && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
<div className="flex items-center gap-4">
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
<span className="text-sm text-gray-400">
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
</span>
</div>
</div>
)}
{/* Activity list */}
<h3 className="text-sm font-medium text-gray-400 mb-2">
All completions ({routeActivities?.length ?? 0})
</h3>
<div className="space-y-1">
{routeActivities?.map((act, i) => (
<Link key={act.id} to={`/activities/${act.id}`}
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate && (
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
)}
{i === 0 && (
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
)}
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors"></span>
</Link>
))}
</div>
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
</div>
</div>
)}
</div> </div>
) )
} }
-365
View File
@@ -1,365 +0,0 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import api from '../utils/api'
import { formatDuration, formatDistance } from '../utils/format'
import RouteMiniMap from '../components/ui/RouteMiniMap'
function formatSegmentDist(m) {
if (m == null) return '--'
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
}
function SegmentRow({ seg, routeId, routePolyline, sportType }) {
const [expanded, setExpanded] = useState(false)
const queryClient = useQueryClient()
const { data: times, isLoading: timesLoading } = useQuery({
queryKey: ['segment-times', routeId, seg.id],
queryFn: () => api.get(`/routes/${routeId}/segments/${seg.id}/times`).then(r => r.data),
})
const deleteMut = useMutation({
mutationFn: () => api.delete(`/routes/${routeId}/segments/${seg.id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['segments', routeId] }),
})
const bestTime = times?.length ? Math.min(...times.map(t => t.duration_s)) : null
const lastTime = times?.[0]?.duration_s ?? null
return (
<div className="border border-gray-800 rounded-lg overflow-hidden">
{/* Main row */}
<div className="flex items-center gap-3 p-3">
{/* Segment mini-map */}
<div className="flex-shrink-0">
<RouteMiniMap
polyline={routePolyline}
sportType={sportType}
width={72}
height={56}
segmentStartM={seg.start_distance_m}
segmentEndM={seg.end_distance_m}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate">{seg.name}</span>
{seg.auto_generated && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">
{seg.auto_generated_type || 'auto'}
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">
{formatSegmentDist(seg.start_distance_m)} {formatSegmentDist(seg.end_distance_m)}
<span className="ml-2 text-gray-600">({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})</span>
</p>
{/* Times preview row */}
{!timesLoading && (
<div className="flex items-center gap-3 mt-1">
{bestTime && (
<span className="text-xs font-mono text-yellow-400">
Best {formatDuration(bestTime)}
</span>
)}
{lastTime && lastTime !== bestTime && (
<span className="text-xs font-mono text-gray-400">
Last {formatDuration(lastTime)}
</span>
)}
{times?.length > 0 && (
<span className="text-xs text-gray-600">
{times.length} run{times.length !== 1 ? 's' : ''}
</span>
)}
{times?.length === 0 && (
<span className="text-xs text-gray-600">No times yet</span>
)}
</div>
)}
{timesLoading && <p className="text-xs text-gray-600 mt-1">Loading times</p>}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{times?.length > 0 && (
<button
onClick={() => setExpanded(v => !v)}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-1 rounded border border-blue-500/30 hover:border-blue-400/50"
>
{expanded ? 'Hide' : 'All'}
</button>
)}
<button
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
className="text-xs text-gray-600 hover:text-red-400 transition-colors"
title="Delete segment"
>
</button>
</div>
</div>
{/* Expanded times list */}
{expanded && times?.length > 0 && (
<div className="border-t border-gray-800 px-3 pb-3 pt-2 space-y-1">
{times.map((t, i) => (
<div key={t.activity_id} className="flex items-center gap-3 text-xs">
<span className={`font-mono font-semibold w-14 ${t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
{formatDuration(t.duration_s)}
</span>
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
{t.name}
</Link>
<span className="text-gray-700 flex-shrink-0">{format(new Date(t.date), 'd MMM yyyy')}</span>
</div>
))}
</div>
)}
</div>
)
}
function NewSegmentForm({ routeId, onCreated }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [startKm, setStartKm] = useState('')
const [endKm, setEndKm] = useState('')
const [open, setOpen] = useState(false)
const mut = useMutation({
mutationFn: (data) => api.post(`/routes/${routeId}/segments`, data).then(r => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['segments', routeId] })
setName(''); setStartKm(''); setEndKm(''); setOpen(false)
if (onCreated) onCreated()
},
})
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="w-full text-left text-xs text-blue-400 hover:text-blue-300 border border-dashed border-blue-500/30 hover:border-blue-400/50 rounded-lg px-3 py-2 transition-colors"
>
+ Add segment manually
</button>
)
}
const handleSubmit = (e) => {
e.preventDefault()
const start = parseFloat(startKm) * 1000
const end = parseFloat(endKm) * 1000
if (!name || isNaN(start) || isNaN(end) || end <= start) return
mut.mutate({ name, start_distance_m: start, end_distance_m: end })
}
return (
<form onSubmit={handleSubmit} className="border border-gray-700 rounded-lg p-3 space-y-2">
<p className="text-xs text-gray-400 font-medium">New segment</p>
<input
type="text" placeholder="Name (e.g. The big hill)"
value={name} onChange={e => setName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
required
/>
<div className="flex gap-2">
<input
type="number" placeholder="Start (km)" step="0.01" min="0"
value={startKm} onChange={e => setStartKm(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
required
/>
<input
type="number" placeholder="End (km)" step="0.01" min="0"
value={endKm} onChange={e => setEndKm(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
required
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={mut.isPending}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm py-1.5 rounded transition-colors">
{mut.isPending ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={() => setOpen(false)}
className="px-4 text-sm text-gray-500 hover:text-gray-300 transition-colors">
Cancel
</button>
</div>
</form>
)
}
export default function SegmentsPage() {
const [selectedRouteId, setSelectedRouteId] = useState(null)
const [autoGenLoading, setAutoGenLoading] = useState(null)
const [hillGradient, setHillGradient] = useState(5)
const queryClient = useQueryClient()
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
const selectedRoute = routes?.find(r => r.id === selectedRouteId)
const { data: segments, isLoading: segsLoading } = useQuery({
queryKey: ['segments', selectedRouteId],
queryFn: () => api.get(`/routes/${selectedRouteId}/segments`).then(r => r.data),
enabled: !!selectedRouteId,
})
const autoGenMut = useMutation({
mutationFn: ({ type, opts }) =>
api.post(`/routes/${selectedRouteId}/segments/auto`, { type, ...opts }).then(r => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['segments', selectedRouteId] })
setAutoGenLoading(null)
},
onError: (err) => {
alert(err?.response?.data?.detail || 'Auto-generate failed')
setAutoGenLoading(null)
},
})
const handleAutoGen = (type, opts = {}) => {
setAutoGenLoading(type)
autoGenMut.mutate({ type, opts })
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Segments</h1>
</div>
{/* Route tile grid */}
{!routes?.length ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
<p className="text-sm text-gray-600">No named routes yet. <Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link></p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{routes.map(r => (
<button
key={r.id}
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
className={`text-left rounded-xl border p-2 transition-colors ${
selectedRouteId === r.id
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
}`}
>
<RouteMiniMap
polyline={r.reference_polyline}
sportType={r.sport_type}
width="100%"
height={80}
/>
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
<div className="flex items-center justify-between mt-0.5">
{r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)}
{r.activity_count > 0 && (
<p className="text-xs text-gray-500">{r.activity_count} run{r.activity_count !== 1 ? 's' : ''}</p>
)}
</div>
</button>
))}
</div>
)}
{selectedRoute && (
<div className="space-y-4">
{/* Route info */}
<div className="flex items-center gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{selectedRoute.name}</h2>
<p className="text-xs text-gray-500">
{selectedRoute.sport_type && <span className="capitalize">{selectedRoute.sport_type}</span>}
{selectedRoute.distance_m && <span> · {formatDistance(selectedRoute.distance_m)}</span>}
{selectedRoute.activity_count > 0 && <span> · {selectedRoute.activity_count} runs</span>}
{selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>}
</p>
</div>
</div>
{/* Auto-generate controls */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<p className="text-xs font-medium text-gray-400">Auto-generate segments</p>
<div className="flex flex-wrap gap-2 items-center">
<button
onClick={() => handleAutoGen('1km')}
disabled={autoGenLoading === '1km'}
className="text-sm px-3 py-1.5 rounded-lg bg-blue-600/20 text-blue-300 border border-blue-500/30 hover:bg-blue-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === '1km' ? 'Generating…' : '📏 1 km splits'}
</button>
<button
onClick={() => handleAutoGen('turns')}
disabled={autoGenLoading === 'turns'}
className="text-sm px-3 py-1.5 rounded-lg bg-purple-600/20 text-purple-300 border border-purple-500/30 hover:bg-purple-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === 'turns' ? 'Generating…' : '↩️ Detect turns'}
</button>
<div className="flex items-center gap-2">
<button
onClick={() => handleAutoGen('hills', { gradient_pct: hillGradient })}
disabled={autoGenLoading === 'hills'}
className="text-sm px-3 py-1.5 rounded-lg bg-green-600/20 text-green-300 border border-green-500/30 hover:bg-green-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === 'hills' ? 'Generating…' : '⛰️ Detect hills'}
</button>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500"></span>
<input
type="number" min="1" max="30" step="1"
value={hillGradient}
onChange={e => setHillGradient(parseInt(e.target.value) || 5)}
className="w-12 bg-gray-800 border border-gray-700 text-white text-xs rounded px-2 py-1 text-center focus:outline-none focus:border-blue-500"
/>
<span className="text-xs text-gray-500">%</span>
</div>
</div>
</div>
<p className="text-xs text-gray-600">Each auto-generate type (splits, turns, hills) replaces only its own previous segments. Manual segments are always kept.</p>
</div>
{/* Segments list */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
{segments?.length > 0 && (
<span className="text-xs text-gray-600">{segments.length} segment{segments.length !== 1 ? 's' : ''}</span>
)}
</div>
{segsLoading && <p className="text-sm text-gray-600">Loading</p>}
{!segsLoading && !segments?.length && (
<p className="text-sm text-gray-600">No segments yet. Use auto-generate above or add one manually.</p>
)}
{segments?.map(seg => (
<SegmentRow
key={seg.id}
seg={seg}
routeId={selectedRouteId}
routePolyline={selectedRoute.reference_polyline}
sportType={selectedRoute.sport_type}
/>
))}
<NewSegmentForm routeId={selectedRouteId} />
</div>
</div>
)}
</div>
)
}
+22 -3
View File
@@ -16,10 +16,17 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
if (data.status === 'SUCCESS' || data.status === 'FAILURE') { if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
clearInterval(intervalsRef.current[taskId]) clearInterval(intervalsRef.current[taskId])
delete intervalsRef.current[taskId] delete intervalsRef.current[taskId]
// A successful task may still have skipped the file (e.g. a duplicate or
// an activity that looks like vehicle travel) — surface the reason.
const skipped = data.status === 'SUCCESS' && data.result?.status === 'skipped'
setTasks(ts => ts.map(t => setTasks(ts => ts.map(t =>
t.task_id === taskId ? { ...t, status: data.status === 'SUCCESS' ? 'done' : 'failed' } : t t.task_id === taskId
? { ...t,
status: data.status === 'FAILURE' ? 'failed' : skipped ? 'skipped' : 'done',
reason: skipped ? data.result?.reason : t.reason }
: t
)) ))
if (data.status === 'SUCCESS') { if (data.status === 'SUCCESS' && !skipped) {
queryClient.invalidateQueries({ queryKey: ['activities'] }) queryClient.invalidateQueries({ queryKey: ['activities'] })
queryClient.invalidateQueries({ queryKey: ['health-summary'] }) queryClient.invalidateQueries({ queryKey: ['health-summary'] })
queryClient.invalidateQueries({ queryKey: ['health-metrics'] }) queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
@@ -50,6 +57,10 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
pollTask(data.task_id) pollTask(data.task_id)
} }
}, },
onError: (err, file) => {
const reason = err.response?.data?.detail || 'Upload failed'
setTasks(t => [...t, { file: file?.name || String(file), status: 'failed', reason }])
},
}) })
const onDrop = useCallback((accepted) => { const onDrop = useCallback((accepted) => {
@@ -65,6 +76,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
function StatusBadge({ status }) { function StatusBadge({ status }) {
if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse"> Processing</span> if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse"> Processing</span>
if (status === 'done') return <span className="ml-2 text-green-400"> Done</span> if (status === 'done') return <span className="ml-2 text-green-400"> Done</span>
if (status === 'skipped') return <span className="ml-2 text-amber-400"> Skipped</span>
if (status === 'failed') return <span className="ml-2 text-red-400"> Failed</span> if (status === 'failed') return <span className="ml-2 text-red-400"> Failed</span>
return <span className="ml-2 text-green-400"> Queued</span> return <span className="ml-2 text-green-400"> Queued</span>
} }
@@ -107,13 +119,20 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
{tasks.length > 0 && ( {tasks.length > 0 && (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{tasks.map((task, i) => ( {tasks.map((task, i) => (
<div key={i} className="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2"> <div key={i} className="bg-gray-800 rounded-lg px-3 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-300 truncate flex-1">{task.file}</span> <span className="text-gray-300 truncate flex-1">{task.file}</span>
{task.activity_tasks !== undefined && ( {task.activity_tasks !== undefined && (
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span> <span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
)} )}
<StatusBadge status={task.status} /> <StatusBadge status={task.status} />
</div> </div>
{task.reason && (task.status === 'skipped' || task.status === 'failed') && (
<p className={`text-xs mt-1 ${task.status === 'skipped' ? 'text-amber-400/80' : 'text-red-400/80'}`}>
{task.reason}
</p>
)}
</div>
))} ))}
</div> </div>
)} )}
+36
View File
@@ -0,0 +1,36 @@
// Shared Body Battery rendering helpers, used by both the Health page chart and
// the Dashboard mini chart so they colour bars identically.
// Colour per inferred state (matches the Health page legend)
export const BB_INFERRED_COLOR = {
sleep: '#4f46e5',
rest: '#0d9488',
activity: '#f97316',
stable: '#374151',
}
export const BB_INFERRED_LABEL = {
sleep: 'Sleep',
rest: 'Rest',
activity: 'Active/Stress',
stable: 'Stable',
}
// Colour a single battery level by magnitude (used for the headline number)
export function bbLevelColor(level) {
if (level == null) return '#6b7280'
if (level >= 75) return '#3b82f6'
if (level >= 50) return '#22c55e'
if (level >= 25) return '#f59e0b'
return '#ef4444'
}
// Classify a sample as sleep / rest (charging) / activity (draining) / stable.
export function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
const inSleep = sleepStartMs != null && sleepEndMs != null && tsMs >= sleepStartMs && tsMs <= sleepEndMs
if (inSleep) return 'sleep'
if (prevLevel != null) {
if (level > prevLevel + 0.3) return 'rest'
if (level < prevLevel - 0.3) return 'activity'
}
return 'stable'
}
+111 -6
View File
@@ -4,6 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import timedelta
from jose import jwt, JWTError
import httpx import httpx
from app.core.database import get_db from app.core.database import get_db
@@ -13,11 +15,59 @@ from app.models.user import User
router = APIRouter() router = APIRouter()
# Marks a short-lived OIDC `state` token as an account-link request (as opposed
# to a normal sign-in), so the callback attaches the passkey to a known user
# instead of creating/looking-up by identity.
LINK_STATE_PURPOSE = "pocketid-link"
def _make_link_state(user_id: int) -> str:
"""Signed, short-lived token carrying 'link this passkey to user_id' intent."""
return create_access_token(
{"sub": str(user_id), "purpose": LINK_STATE_PURPOSE},
expires_delta=timedelta(minutes=10),
)
def _decode_link_state(state: Optional[str]) -> Optional[int]:
"""Return the user id from a valid link-state token, else None."""
if not state:
return None
try:
payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("purpose") != LINK_STATE_PURPOSE:
return None
return int(payload["sub"])
except (JWTError, KeyError, TypeError, ValueError):
return None
async def _config_admin(db: AsyncSession):
"""The admin row that holds instance-wide PocketID settings.
Settings live on an admin user row, but there can be more than one admin.
Prefer the admin that actually has an issuer configured; otherwise fall back
to the lowest-id admin. Without this, an unordered LIMIT 1 could return an
admin with no config and make PocketID look disabled / gating inconsistent.
"""
result = await db.execute(
select(User)
.where(User.is_admin == True, User.pocketid_issuer.isnot(None))
.order_by(User.id)
.limit(1)
)
admin = result.scalar_one_or_none()
if admin is None:
result = await db.execute(
select(User).where(User.is_admin == True).order_by(User.id).limit(1)
)
admin = result.scalar_one_or_none()
return admin
async def _get_pocketid_config(db: AsyncSession): async def _get_pocketid_config(db: AsyncSession):
"""Get PocketID config from DB (admin user) falling back to env vars.""" """Get PocketID config from DB (admin user) falling back to env vars."""
result = await db.execute(select(User).where(User.is_admin == True).limit(1)) admin = await _config_admin(db)
admin = result.scalar_one_or_none()
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id
client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret
@@ -26,8 +76,7 @@ async def _get_pocketid_config(db: AsyncSession):
async def _get_allowed_group(db: AsyncSession): async def _get_allowed_group(db: AsyncSession):
"""Group a PocketID user must belong to in order to sign in (None = allow all).""" """Group a PocketID user must belong to in order to sign in (None = allow all)."""
result = await db.execute(select(User).where(User.is_admin == True).limit(1)) admin = await _config_admin(db)
admin = result.scalar_one_or_none()
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
return (group or "").strip() or None return (group or "").strip() or None
@@ -58,6 +107,7 @@ class UserOut(BaseModel):
username: str username: str
email: Optional[str] email: Optional[str]
is_admin: bool is_admin: bool
has_passkey: bool = False
class Config: class Config:
from_attributes = True from_attributes = True
@@ -81,7 +131,13 @@ async def login(
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)): async def get_me(current_user: User = Depends(get_current_user)):
return current_user return UserOut(
id=current_user.id,
username=current_user.username,
email=current_user.email,
is_admin=current_user.is_admin,
has_passkey=current_user.pocketid_sub is not None,
)
@router.get("/pocketid/available") @router.get("/pocketid/available")
@@ -105,8 +161,32 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
return {"url": f"{issuer}/authorize?{urlencode(params)}"} return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/link-url")
async def pocketid_link_url(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Authenticated user starts an OIDC flow to attach a passkey to THEIR account.
The `state` carries a signed 'link to this user' token so the callback links
the returned identity instead of creating/matching a new account.
"""
issuer, client_id, _ = await _get_pocketid_config(db)
if not issuer or not client_id:
raise HTTPException(status_code=404, detail="PocketID not configured")
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email groups",
"state": _make_link_state(current_user.id),
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/callback") @router.get("/pocketid/callback")
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSession = Depends(get_db)):
issuer, client_id, client_secret = await _get_pocketid_config(db) issuer, client_id, client_secret = await _get_pocketid_config(db)
if not issuer: if not issuer:
raise HTTPException(status_code=404, detail="PocketID not configured") raise HTTPException(status_code=404, detail="PocketID not configured")
@@ -119,6 +199,7 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
"client_id": client_id, "client_secret": client_secret}, "client_id": client_id, "client_secret": client_secret},
) )
if resp.status_code != 200: if resp.status_code != 200:
print(f"PocketID token exchange failed ({resp.status_code}): {resp.text}")
raise HTTPException(status_code=400, detail="Token exchange failed") raise HTTPException(status_code=400, detail="Token exchange failed")
tokens = resp.json() tokens = resp.json()
userinfo_resp = await client.get( userinfo_resp = await client.get(
@@ -133,6 +214,30 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
email = userinfo.get("email") email = userinfo.get("email")
preferred_username = userinfo.get("preferred_username") or email preferred_username = userinfo.get("preferred_username") or email
# ── Explicit account-link flow ──────────────────────────────────────────
# Initiated by an already-authenticated user from their profile. Attach the
# passkey to that account. No group gating here: this is identity linking,
# not access control, and the initiator is already an authorised user.
link_user_id = _decode_link_state(state)
if link_user_id is not None:
result = await db.execute(select(User).where(User.pocketid_sub == sub))
holder = result.scalar_one_or_none()
if holder and holder.id != link_user_id:
# This passkey is already attached to a different account.
return RedirectResponse(url="/login?auth_error=passkey_in_use")
result = await db.execute(select(User).where(User.id == link_user_id))
target = result.scalar_one_or_none()
if target is None:
return RedirectResponse(url="/login?auth_error=link_failed")
target.pocketid_sub = sub
if not target.email and email:
dup = await db.execute(
select(User).where(User.email == email, User.id != target.id)
)
if dup.scalar_one_or_none() is None:
target.email = email
return RedirectResponse(url="/profile?linked=1")
# Group gating: if an allowed group is configured, the user must be in it. # Group gating: if an allowed group is configured, the user must be in it.
allowed_group = await _get_allowed_group(db) allowed_group = await _get_allowed_group(db)
if allowed_group: if allowed_group:
+4 -2
View File
@@ -154,8 +154,10 @@ async def save_pocketid_config(
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
if body.client_id is not None: if body.client_id is not None:
current_user.pocketid_client_id = body.client_id or None current_user.pocketid_client_id = body.client_id or None
if body.client_secret is not None: # Only overwrite the secret when a non-empty value is supplied; a blank
current_user.pocketid_client_secret = body.client_secret or None # field means "keep the existing secret" (matches the UI hint).
if body.client_secret:
current_user.pocketid_client_secret = body.client_secret
if body.allowed_group is not None: if body.allowed_group is not None:
current_user.pocketid_allowed_group = body.allowed_group.strip() or None current_user.pocketid_allowed_group = body.allowed_group.strip() or None
await db.commit() await db.commit()
@@ -4,15 +4,17 @@ import { useAuthStore } from '../hooks/useAuth'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import api from '../utils/api' import api from '../utils/api'
const AUTH_ERRORS = {
not_authorized: "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group.",
passkey_in_use: "That passkey is already linked to another account. Sign in to that account, or have an admin remove it on the Users page, then try linking again.",
link_failed: "Couldn't link the passkey. Please try again.",
}
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const authError = new URLSearchParams(window.location.search).get('auth_error') const authError = new URLSearchParams(window.location.search).get('auth_error')
const [error, setError] = useState( const [error, setError] = useState(AUTH_ERRORS[authError] || '')
authError === 'not_authorized'
? "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group."
: ''
)
const { login, isLoading } = useAuthStore() const { login, isLoading } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
@@ -43,13 +43,34 @@ function SaveButton({ onClick, loading, saved, label = 'Save' }) {
export default function ProfilePage() { export default function ProfilePage() {
const qc = useQueryClient() const qc = useQueryClient()
const { user } = useAuthStore() const { user, fetchUser } = useAuthStore()
const { data: profile } = useQuery({ const { data: profile } = useQuery({
queryKey: ['profile'], queryKey: ['profile'],
queryFn: () => api.get('/profile/').then(r => r.data), queryFn: () => api.get('/profile/').then(r => r.data),
}) })
// Passkey linking (available to all users when PocketID is configured)
const { data: pocketidAvailable } = useQuery({
queryKey: ['pocketid-available'],
queryFn: () => api.get('/auth/pocketid/available').then(r => r.data),
})
const [showLinked, setShowLinked] = useState(
new URLSearchParams(window.location.search).get('linked') === '1'
)
useEffect(() => {
if (showLinked) {
fetchUser() // refresh has_passkey
window.history.replaceState({}, '', '/profile')
const t = setTimeout(() => setShowLinked(false), 6000)
return () => clearTimeout(t)
}
}, [])
const handleLinkPasskey = async () => {
const { data } = await api.get('/auth/pocketid/link-url')
window.location.href = data.url
}
const { data: pocketidConfig } = useQuery({ const { data: pocketidConfig } = useQuery({
queryKey: ['pocketid-config'], queryKey: ['pocketid-config'],
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data), queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
@@ -325,6 +346,27 @@ export default function ProfilePage() {
/> />
</Section> </Section>
{/* Passkey sign-in — available to all users when PocketID is configured */}
{pocketidAvailable?.available && (
<Section title="🔑 Passkey sign-in">
{user?.has_passkey ? (
<p className="text-sm text-green-400"> A passkey is linked to this account you can sign in with PocketID.</p>
) : (
<>
<p className="text-xs text-gray-500">
Link your PocketID passkey to <strong>this</strong> account so you can sign in with a passkey
instead of being given a separate, empty account.
</p>
<button onClick={handleLinkPasskey}
className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2">
🔑 Link a passkey to this account
</button>
</>
)}
{showLinked && <p className="text-green-400 text-sm"> Passkey linked to your account.</p>}
</Section>
)}
{/* Garmin Connect Sync */} {/* Garmin Connect Sync */}
<Section title="⌚ Garmin Connect Sync"> <Section title="⌚ Garmin Connect Sync">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">