Compare commits
14 Commits
bc4d68da07
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec87f68729 | |||
| 057eb9391a | |||
| 01a8fe135c | |||
| d350e9caea | |||
| bdd5f80c7e | |||
| 04689a29bd | |||
| 6a1726e0c3 | |||
| 0aa27713ca | |||
| bc437cce92 | |||
| e5feeb1178 | |||
| e0ddc4cbf4 | |||
| 0e18ef2291 | |||
| 0dd6eba589 | |||
| 0e4bc7b444 |
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Node / frontend build artifacts
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# OS noise
|
||||||
|
.DS_Store
|
||||||
@@ -29,6 +29,23 @@ Everything runs in Docker Compose. There is no way to run individual services wi
|
|||||||
|
|
||||||
The app is served on port 80 by nginx, which proxies `/api/*` to the backend (port 8000) and serves the React SPA for everything else.
|
The app is served on port 80 by nginx, which proxies `/api/*` to the backend (port 8000) and serves the React SPA for everything else.
|
||||||
|
|
||||||
|
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).
|
||||||
@@ -36,6 +53,12 @@ The app is served on port 80 by nginx, which proxies `/api/*` to the backend (po
|
|||||||
|
|
||||||
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
|
||||||
@@ -56,6 +79,7 @@ docker compose -f docker-compose.deploy.yml up -d
|
|||||||
| `redis` | Celery broker + result backend |
|
| `redis` | Celery broker + result backend |
|
||||||
| `backend` | FastAPI (async) — uvicorn, single worker |
|
| `backend` | FastAPI (async) — uvicorn, single worker |
|
||||||
| `worker` | Celery worker — synchronous SQLAlchemy (asyncio incompatible with prefork) |
|
| `worker` | Celery worker — synchronous SQLAlchemy (asyncio incompatible with prefork) |
|
||||||
|
| `beat` | Celery Beat scheduler — runs `sync_all_garmin_connect` every 30 minutes |
|
||||||
| `frontend` | React SPA built by Vite at container build time |
|
| `frontend` | React SPA built by Vite at container build time |
|
||||||
| `nginx` | Reverse proxy, serves the SPA |
|
| `nginx` | Reverse proxy, serves the SPA |
|
||||||
|
|
||||||
@@ -63,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`
|
- `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
|
||||||
|
|
||||||
@@ -86,10 +110,11 @@ docker compose -f docker-compose.deploy.yml up -d
|
|||||||
- `App.jsx` — React Router v6, `RequireAuth` wrapper, all routes defined here
|
- `App.jsx` — React Router v6, `RequireAuth` wrapper, all routes defined here
|
||||||
- `hooks/useAuth.js` — Zustand store for auth state, reads JWT from `localStorage`, handles PocketID token-in-URL flow
|
- `hooks/useAuth.js` — Zustand store for auth state, reads JWT from `localStorage`, handles PocketID token-in-URL flow
|
||||||
- `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
|
||||||
- `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.
|
||||||
|
|
||||||
@@ -100,12 +125,20 @@ Required in `.env` (or passed to Docker Compose):
|
|||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `DATABASE_URL` | Full async DB URL (`postgresql+asyncpg://...`) |
|
| `DATABASE_URL` | Full async DB URL (`postgresql+asyncpg://...`) |
|
||||||
| `SECRET_KEY` | JWT signing key — generate with `openssl rand -hex 32` |
|
| `SECRET_KEY` | JWT signing key — generate with `openssl rand -hex 32`; also used as Fernet key for Garmin credentials |
|
||||||
|
| `ADMIN_USERNAME` | Admin account username (default: `admin`) |
|
||||||
| `ADMIN_PASSWORD` | Seeds the admin user on first start |
|
| `ADMIN_PASSWORD` | Seeds the admin user on first start |
|
||||||
| `REDIS_URL` | Celery broker |
|
| `REDIS_URL` | Celery broker |
|
||||||
|
| `DB_USER` / `DB_PASSWORD` | Postgres credentials (compose-level; default: `milevault`) |
|
||||||
|
| `REDIS_PASSWORD` | Redis auth (compose-level; default: `milevault`) |
|
||||||
|
| `HTTP_PORT` | Host port for nginx (default: `80`) |
|
||||||
|
| `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.
Binary file not shown.
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+207
-10
@@ -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,17 +15,105 @@ 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
|
||||||
return issuer, client_id, client_secret
|
return issuer, client_id, client_secret
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_allowed_group(db: AsyncSession):
|
||||||
|
"""Group a PocketID user must belong to in order to sign in (None = allow all)."""
|
||||||
|
admin = await _config_admin(db)
|
||||||
|
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
|
||||||
|
return (group or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
async def _unique_username(db: AsyncSession, base: str) -> str:
|
||||||
|
"""Return `base`, or `base-2`, `base-3`, … until it is not already taken."""
|
||||||
|
base = (base or "user").strip() or "user"
|
||||||
|
candidate = base
|
||||||
|
n = 1
|
||||||
|
while True:
|
||||||
|
existing = await db.execute(select(User).where(User.username == candidate))
|
||||||
|
if existing.scalar_one_or_none() is None:
|
||||||
|
return candidate
|
||||||
|
n += 1
|
||||||
|
candidate = f"{base}-{n}"
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
@@ -37,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
|
||||||
@@ -49,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",
|
||||||
@@ -60,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")
|
||||||
@@ -79,13 +181,38 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
|||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"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",
|
"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")
|
||||||
@@ -98,25 +225,95 @@ 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
|
||||||
|
|
||||||
sub = userinfo.get("sub")
|
sub = userinfo.get("sub")
|
||||||
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.
|
||||||
|
allowed_group = await _get_allowed_group(db)
|
||||||
|
if allowed_group:
|
||||||
|
groups = userinfo.get("groups") or []
|
||||||
|
if allowed_group not in groups:
|
||||||
|
return RedirectResponse(url="/login?auth_error=not_authorized")
|
||||||
|
|
||||||
|
# 1) Existing passkey identity → use it.
|
||||||
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 2) No passkey identity yet, but an account with this email exists and is
|
||||||
|
# not already linked to a different passkey → link them (preserves data).
|
||||||
|
if not user and email:
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing and existing.pocketid_sub is None:
|
||||||
|
existing.pocketid_sub = sub
|
||||||
|
existing.hashed_password = None # disable password login once passkey is linked
|
||||||
|
user = existing
|
||||||
|
|
||||||
|
# 3) Otherwise provision a new account with a collision-safe username.
|
||||||
if not user:
|
if not user:
|
||||||
user = User(username=preferred_username, email=email, pocketid_sub=sub)
|
base = preferred_username or (email.split("@")[0] if email else "user")
|
||||||
|
username = await _unique_username(db, base)
|
||||||
|
# Only set email if no other account already claims it (unique column).
|
||||||
|
email_taken = False
|
||||||
|
if email:
|
||||||
|
dup = await db.execute(select(User).where(User.email == email))
|
||||||
|
email_taken = dup.scalar_one_or_none() is not None
|
||||||
|
user = User(username=username, email=None if email_taken else email, pocketid_sub=sub)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
token = create_access_token({"sub": str(user.id)})
|
token = create_access_token({"sub": str(user.id)})
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
return RedirectResponse(url=f"/?token={token}")
|
return RedirectResponse(url=f"/?token={token}")
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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 20–500 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)
|
||||||
@@ -121,6 +127,7 @@ class PocketIDConfig(BaseModel):
|
|||||||
issuer: Optional[str] = None
|
issuer: Optional[str] = None
|
||||||
client_id: Optional[str] = None
|
client_id: Optional[str] = None
|
||||||
client_secret: Optional[str] = None
|
client_secret: Optional[str] = None
|
||||||
|
allowed_group: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pocketid-config")
|
@router.get("/pocketid-config")
|
||||||
@@ -131,10 +138,12 @@ async def get_pocketid_config(current_user: User = Depends(get_current_user)):
|
|||||||
# Show DB config if set, fall back to env
|
# Show DB config if set, fall back to env
|
||||||
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
|
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
|
||||||
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
|
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
|
||||||
|
allowed_group = current_user.pocketid_allowed_group or settings.pocketid_allowed_group
|
||||||
return {
|
return {
|
||||||
"issuer": issuer or "",
|
"issuer": issuer or "",
|
||||||
"client_id": client_id or "",
|
"client_id": client_id or "",
|
||||||
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
|
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
|
||||||
|
"allowed_group": allowed_group or "",
|
||||||
"enabled": bool(issuer and client_id),
|
"enabled": bool(issuer and client_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,8 +160,12 @@ 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:
|
||||||
|
current_user.pocketid_allowed_group = body.allowed_group.strip() or None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -179,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),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
+107
-41
@@ -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 = []
|
||||||
with zipfile.ZipFile(dest) as zf:
|
try:
|
||||||
zf.extractall(extract_dir)
|
with zipfile.ZipFile(dest) as zf:
|
||||||
for name in zf.namelist():
|
extracted = _safe_extract(zf, extract_dir)
|
||||||
lower = name.lower()
|
except zipfile.BadZipFile:
|
||||||
if lower.endswith(".fit"):
|
dest.unlink(missing_ok=True)
|
||||||
fit_path = extract_dir / name
|
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
|
||||||
task_ids.append(task.id)
|
has_health = False
|
||||||
elif lower.endswith(".zip"):
|
for path in extracted:
|
||||||
# Garmin exports nest activity FIT files inside sub-zips
|
suffix = path.suffix.lower()
|
||||||
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
|
if suffix == ".fit":
|
||||||
nested_zip_path = extract_dir / name
|
task = process_activity_file.delay(str(path), current_user.id, "fit")
|
||||||
nested_extract = nested_zip_path.parent / nested_zip_path.stem
|
task_ids.append(task.id)
|
||||||
nested_extract.mkdir(exist_ok=True)
|
elif suffix == ".json":
|
||||||
try:
|
has_health = True # Garmin wellness data is exported as JSON files
|
||||||
with zipfile.ZipFile(nested_zip_path) as nzf:
|
elif suffix == ".zip":
|
||||||
nzf.extractall(nested_extract)
|
# Garmin exports nest activity FIT files inside sub-zips
|
||||||
for nested_name in nzf.namelist():
|
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
|
||||||
if nested_name.lower().endswith(".fit"):
|
nested_extract = path.parent / path.stem
|
||||||
fit_path = nested_extract / nested_name
|
try:
|
||||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
with zipfile.ZipFile(path) as nzf:
|
||||||
task_ids.append(task.id)
|
nested = _safe_extract(nzf, nested_extract)
|
||||||
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,18 +174,26 @@ 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 = []
|
||||||
with zipfile.ZipFile(dest) as zf:
|
try:
|
||||||
zf.extractall(extract_dir)
|
with zipfile.ZipFile(dest) as zf:
|
||||||
for name in zf.namelist():
|
extracted = _safe_extract(zf, extract_dir)
|
||||||
lower = name.lower()
|
except zipfile.BadZipFile:
|
||||||
if lower.endswith(".fit") or lower.endswith(".gpx"):
|
dest.unlink(missing_ok=True)
|
||||||
file_path = extract_dir / name
|
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||||
ext = Path(name).suffix[1:]
|
|
||||||
task = process_activity_file.delay(str(file_path), current_user.id, ext)
|
for path in extracted:
|
||||||
task_ids.append(task.id)
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in (".fit", ".gpx"):
|
||||||
|
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
|
||||||
|
task_ids.append(task.id)
|
||||||
|
|
||||||
|
if not task_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="No activity files (.fit or .gpx) found in this Strava archive",
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Admin-only user management: list provisioned users, promote/demote admin,
|
||||||
|
and delete a user together with all of their data.
|
||||||
|
|
||||||
|
New users are normally provisioned just-in-time on first PocketID login
|
||||||
|
(see app/api/auth.py). This router is the in-app surface for managing them.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, delete, func
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.user import (
|
||||||
|
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
|
||||||
|
Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(current_user: User):
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(403, "Admin only")
|
||||||
|
|
||||||
|
|
||||||
|
async def _admin_count(db: AsyncSession) -> int:
|
||||||
|
result = await db.execute(select(func.count()).select_from(User).where(User.is_admin == True))
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: Optional[str]
|
||||||
|
is_admin: bool
|
||||||
|
has_passkey: bool
|
||||||
|
activity_count: int
|
||||||
|
created_at: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUpdate(BaseModel):
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_users(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_admin(current_user)
|
||||||
|
# activity counts per user in one grouped query
|
||||||
|
counts = dict(
|
||||||
|
(await db.execute(
|
||||||
|
select(Activity.user_id, func.count(Activity.id)).group_by(Activity.user_id)
|
||||||
|
)).all()
|
||||||
|
)
|
||||||
|
result = await db.execute(select(User).order_by(User.id))
|
||||||
|
users = result.scalars().all()
|
||||||
|
return [
|
||||||
|
UserOut(
|
||||||
|
id=u.id,
|
||||||
|
username=u.username,
|
||||||
|
email=u.email,
|
||||||
|
is_admin=u.is_admin,
|
||||||
|
has_passkey=u.pocketid_sub is not None,
|
||||||
|
activity_count=counts.get(u.id, 0),
|
||||||
|
created_at=u.created_at.isoformat() if u.created_at else None,
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}")
|
||||||
|
async def set_admin(
|
||||||
|
user_id: int,
|
||||||
|
body: AdminUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_admin(current_user)
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(400, "You cannot change your own admin status")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
|
||||||
|
# Demoting the last remaining admin would lock everyone out.
|
||||||
|
if user.is_admin and not body.is_admin and await _admin_count(db) <= 1:
|
||||||
|
raise HTTPException(400, "Cannot demote the last admin")
|
||||||
|
|
||||||
|
user.is_admin = body.is_admin
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok", "is_admin": user.is_admin}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_admin(current_user)
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(400, "You cannot delete your own account")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
if user.is_admin and await _admin_count(db) <= 1:
|
||||||
|
raise HTTPException(400, "Cannot delete the last admin")
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
activity_ids = select(Activity.id).where(Activity.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(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
|
||||||
|
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_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(NamedRoute).where(NamedRoute.user_id == user_id))
|
||||||
|
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
|
||||||
|
await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id))
|
||||||
|
await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id))
|
||||||
|
await db.execute(delete(User).where(User.id == user_id))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Remove the user's uploaded files from disk (best-effort).
|
||||||
|
shutil.rmtree(Path(settings.file_store_path) / str(user_id), ignore_errors=True)
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -21,6 +21,9 @@ class Settings(BaseSettings):
|
|||||||
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
|
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
|
||||||
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")
|
||||||
|
# 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
|
||||||
|
|||||||
+24
-7
@@ -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
|
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:
|
||||||
@@ -73,17 +82,23 @@ async def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"users.biological_sex column migration skipped: {e}")
|
print(f"users.biological_sex column migration skipped: {e}")
|
||||||
|
|
||||||
# route_segments auto_generated column added after initial creation
|
# pocketid_allowed_group 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 pocketid_allowed_group VARCHAR(128)"
|
||||||
))
|
|
||||||
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.pocketid_allowed_group column migration skipped: {e}")
|
||||||
|
|
||||||
|
# goal_weight_kg column on users added after initial creation
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS goal_weight_kg FLOAT"
|
||||||
|
))
|
||||||
|
except Exception as 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:
|
||||||
@@ -215,6 +230,8 @@ app.include_router(records.router, prefix="/api/records", tags=["records"])
|
|||||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||||
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
||||||
|
app.include_router(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.
+38
-11
@@ -28,11 +28,14 @@ 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)
|
||||||
pocketid_client_id = Column(String(256), nullable=True)
|
pocketid_client_id = Column(String(256), nullable=True)
|
||||||
pocketid_client_secret = Column(String(256), nullable=True)
|
pocketid_client_secret = Column(String(256), nullable=True)
|
||||||
|
# Only PocketID users in this group may sign in. Null/blank = allow all.
|
||||||
|
pocketid_allowed_group = Column(String(128), nullable=True)
|
||||||
|
|
||||||
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||||
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||||
@@ -89,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)
|
||||||
@@ -170,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):
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
sp = seg_coords[int(frac * (m - 1))]
|
||||||
|
if not any(haversine_m(act_coords[k], sp) <= tol_m for k in range(si, ei + 1)):
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
|
|
||||||
if start_time is not None and dist >= end_dist_m:
|
dur = (act_times[ei] - act_times[si]).total_seconds()
|
||||||
end_time = ts
|
if dur > 0 and (best is None or dur < best):
|
||||||
break
|
best = dur
|
||||||
|
|
||||||
if start_time and end_time:
|
return best
|
||||||
from datetime import datetime
|
|
||||||
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
|
|
||||||
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
|
|
||||||
return (t2 - t1).total_seconds()
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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"),
|
||||||
|
|||||||
Binary file not shown.
+263
-21
@@ -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,31 +722,51 @@ def sync_garmin_connect_user(user_id: int):
|
|||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
def _set_status(text):
|
def _set_status(text):
|
||||||
|
# Checked between items: abort the sync if cancellation was requested.
|
||||||
|
if _cancelled():
|
||||||
|
raise SyncCancelled()
|
||||||
cfg.last_sync_status = text
|
cfg.last_sync_status = text
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
if sync_acts:
|
try:
|
||||||
_set_status("Syncing activities...")
|
if sync_acts:
|
||||||
try:
|
_set_status("Syncing activities...")
|
||||||
activities_queued = sync_activities(
|
try:
|
||||||
garmin, user_id, last_sync_at, db, settings.file_store_path,
|
activities_queued = sync_activities(
|
||||||
lookback_days=lookback,
|
garmin, user_id, last_sync_at, db, settings.file_store_path,
|
||||||
status_callback=_set_status,
|
lookback_days=lookback,
|
||||||
)
|
status_callback=_set_status,
|
||||||
except Exception as exc:
|
)
|
||||||
errors.append(f"activities: {exc}")
|
except SyncCancelled:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"activities: {exc}")
|
||||||
|
|
||||||
if sync_well:
|
if sync_well:
|
||||||
_set_status("Syncing wellness...")
|
_set_status("Syncing wellness...")
|
||||||
|
try:
|
||||||
|
wellness_days = sync_wellness(
|
||||||
|
garmin, user_id, last_sync_at, db,
|
||||||
|
lookback_days=lookback,
|
||||||
|
status_callback=_set_status,
|
||||||
|
)
|
||||||
|
except SyncCancelled:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"wellness: {exc}")
|
||||||
|
db.rollback() # recover session so the final status commit can succeed
|
||||||
|
except SyncCancelled:
|
||||||
|
db.rollback()
|
||||||
|
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||||
|
cfg.last_sync_status = "Cancelled"
|
||||||
|
db.commit()
|
||||||
try:
|
try:
|
||||||
wellness_days = sync_wellness(
|
if _redis:
|
||||||
garmin, user_id, last_sync_at, db,
|
_redis.delete(cancel_key)
|
||||||
lookback_days=lookback,
|
except Exception:
|
||||||
status_callback=_set_status,
|
pass
|
||||||
)
|
return {"status": "cancelled",
|
||||||
except Exception as exc:
|
"activities_queued": activities_queued, "wellness_days": wellness_days}
|
||||||
errors.append(f"wellness: {exc}")
|
|
||||||
db.rollback() # recover session so the final status commit can succeed
|
|
||||||
|
|
||||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||||
cfg.last_sync_status = (
|
cfg.last_sync_status = (
|
||||||
@@ -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}
|
||||||
|
|||||||
Generated
+3468
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,10 @@ 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'
|
||||||
|
import UsersPage from './pages/UsersPage'
|
||||||
|
|
||||||
function RequireAuth({ children }) {
|
function RequireAuth({ children }) {
|
||||||
const token = useAuthStore((s) => s.token)
|
const token = useAuthStore((s) => s.token)
|
||||||
@@ -35,10 +35,10 @@ 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 />} />
|
||||||
|
<Route path="users" element={<UsersPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) {
|
const dot = (color) => L.divIcon({
|
||||||
|
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
||||||
|
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pulsing target dot shown under the cursor while drawing a segment, so the user
|
||||||
|
// can see exactly which track point a click will snap to.
|
||||||
|
const SEG_TARGET_ICON = L.divIcon({
|
||||||
|
html: '<div style="width:14px;height:14px;background:#22c55e;border:2px solid #fff;border-radius:50%;box-shadow:0 0 8px rgba(34,197,94,0.9)"></div>',
|
||||||
|
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nearest recorded GPS point to a lat/lng (squared planar distance is fine at
|
||||||
|
// these scales). Mirrors nearestDistance() in ActivityDetailPage.
|
||||||
|
function nearestPoint(points, lat, lng) {
|
||||||
|
let best = null, bestD = Infinity
|
||||||
|
for (const p of points) {
|
||||||
|
if (p.latitude == null || p.longitude == null) continue
|
||||||
|
const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2
|
||||||
|
if (d < bestD) { bestD = d; best = p }
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
|
||||||
if (trackRef.current) {
|
if (trackRef.current) {
|
||||||
trackRef.current.remove()
|
trackRef.current.remove()
|
||||||
trackRef.current = null
|
trackRef.current = null
|
||||||
}
|
}
|
||||||
if (!polyline) return
|
|
||||||
|
|
||||||
|
// 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 5th–95th 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)
|
const coords = decodePolyline(polyline)
|
||||||
if (!coords.length) return
|
if (!coords.length) return
|
||||||
|
L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }).addTo(group)
|
||||||
trackRef.current = L.polyline(coords, {
|
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
|
||||||
color: sportColor(sportType),
|
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
|
||||||
weight: 3,
|
group.addTo(map)
|
||||||
opacity: 0.9,
|
trackRef.current = group
|
||||||
}).addTo(map)
|
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
|
||||||
|
|
||||||
map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
|
|
||||||
|
|
||||||
const dot = (color) => L.divIcon({
|
|
||||||
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
|
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
|
||||||
|
|||||||
@@ -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,25 +22,40 @@ export default function LapTable({ laps, sportType }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{laps.map((lap) => (
|
{laps.map((lap) => {
|
||||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
const best = hasBests ? lapBests[String(lap.lap_number)] : null
|
||||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null
|
||||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
const isBest = delta != null && delta <= 0.5
|
||||||
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td>
|
return (
|
||||||
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
|
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
||||||
</td>
|
<td className={`py-2 text-right ${isBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
|
||||||
<td className="py-2 text-right text-gray-400">
|
{hasBests && (
|
||||||
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
|
||||||
</td>
|
)}
|
||||||
{showPower && (
|
{hasBests && (
|
||||||
<td className="py-2 text-right text-gray-400">
|
<td className={`py-2 text-right font-mono ${
|
||||||
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
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">
|
||||||
|
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||||
</td>
|
</td>
|
||||||
)}
|
<td className="py-2 text-right text-gray-400">
|
||||||
</tr>
|
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
||||||
))}
|
</td>
|
||||||
|
{showPower && (
|
||||||
|
<td className="py-2 text-right text-gray-400">
|
||||||
|
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</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,24 +166,32 @@ 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}
|
||||||
/>
|
/>
|
||||||
<Line
|
{metric.key === 'cadence' && sportType === 'running' ? (
|
||||||
type="monotone"
|
<>
|
||||||
dataKey={metric.key}
|
{/* 165 spm guide → 82.5 in stored (halved) units */}
|
||||||
stroke={metric.color}
|
<ReferenceLine y={82.5} stroke="#22c55e" strokeDasharray="4 4" strokeWidth={1.5} />
|
||||||
strokeWidth={1.5}
|
<Scatter dataKey="cadence" shape={renderCadenceDot} isAnimationActive={false} />
|
||||||
dot={false}
|
</>
|
||||||
isAnimationActive={false}
|
) : (
|
||||||
connectNulls={false}
|
<Line
|
||||||
/>
|
type="monotone"
|
||||||
|
dataKey={metric.key}
|
||||||
|
stroke={metric.color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,57 +1,127 @@
|
|||||||
|
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: '⚙️' },
|
||||||
|
{ to: '/users', label: 'Users', icon: '👥', adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
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'}`}>
|
||||||
<h1 className="text-lg font-bold text-white tracking-tight">
|
{!collapsed && (
|
||||||
<span className="text-blue-400">Mile</span>Vault
|
<h1 className="text-lg font-bold text-white tracking-tight">
|
||||||
</h1>
|
<span className="text-blue-400">Mile</span>Vault
|
||||||
{user && <p className="text-xs text-gray-500 mt-0.5">@{user.username}{user.is_admin ? ' · admin' : ''}</p>}
|
</h1>
|
||||||
|
)}
|
||||||
|
<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.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" />
|
||||||
</button>
|
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>
|
||||||
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -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>
|
)}
|
||||||
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 mb-1">
|
{actSegments && actSegments.length > 0 && (
|
||||||
<span className="flex-1 text-xs text-gray-600 uppercase tracking-wide">Segment</span>
|
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">This run</span>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
|
||||||
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Best</span>
|
<SegmentsPanel segments={actSegments} activityId={Number(id)} />
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 }}>
|
<Tooltip
|
||||||
<defs>
|
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11, color: '#fff' }}
|
||||||
<linearGradient id="bbGrad" x1="0" y1="0" x2="0" y2="1">
|
itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
formatter={v => [`${Math.round(v)}%`, 'Battery']}
|
||||||
</linearGradient>
|
/>
|
||||||
</defs>
|
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
||||||
<Area type="monotone" dataKey="level" stroke={color} strokeWidth={1.5}
|
{data.map((d, i) => <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />)}
|
||||||
fill="url(#bbGrad)" dot={false} isAnimationActive={false} />
|
</Bar>
|
||||||
<Tooltip
|
</BarChart>
|
||||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11 }}
|
</ResponsiveContainer>
|
||||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
</div>
|
||||||
formatter={v => [`${Math.round(v)}`, 'Battery']}
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||||
/>
|
{presentTypes.map(type => (
|
||||||
</AreaChart>
|
<div key={type} className="flex items-center gap-1">
|
||||||
</ResponsiveContainer>
|
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
|
||||||
</div>
|
<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">
|
||||||
|
|||||||
@@ -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)
|
||||||
stroke="rgba(255,255,255,0.3)"
|
if (x1 == null || x2 == null) return null
|
||||||
strokeWidth={1.5}
|
return (
|
||||||
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
|
<ReferenceArea
|
||||||
/>
|
key={`area-${a.id}`}
|
||||||
))}
|
x1={x1}
|
||||||
|
x2={x2}
|
||||||
|
fill="rgba(255,255,255,0.16)"
|
||||||
|
stroke="rgba(255,255,255,0.3)"
|
||||||
|
strokeWidth={1}
|
||||||
|
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</BarChart>
|
</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
|
||||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
return (
|
||||||
rangeDays === days
|
<button key={label}
|
||||||
? 'bg-blue-600 border-blue-600 text-white'
|
onClick={() => !disabled && setRangeDays(days)}
|
||||||
: 'border-gray-700 text-gray-400 hover:text-white'
|
disabled={disabled}
|
||||||
}`}>
|
title={disabled ? 'Not enough history for this range' : undefined}
|
||||||
{label}
|
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||||
</button>
|
disabled
|
||||||
))}
|
? 'border-gray-800 text-gray-700 opacity-40 cursor-not-allowed'
|
||||||
|
: rangeDays === days
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: 'border-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Score</h3>
|
||||||
|
<MetricChart data={metrics} dataKey="sleep_score" color="#818cf8"
|
||||||
|
formatter={v => Math.round(v)}
|
||||||
|
domain={[0, 100]}
|
||||||
|
connectNulls showDots
|
||||||
|
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||||
|
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">
|
<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>
|
<WeightChart
|
||||||
<MetricChart
|
data={metrics}
|
||||||
data={metrics.filter(d => d.weight_kg != null)}
|
goalKg={profile?.goal_weight_kg}
|
||||||
dataKey="weight_kg" color="#34d399"
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
formatter={v => `${v.toFixed(1)} kg`}
|
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
|
||||||
connectNulls showDots />
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -4,10 +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 [error, setError] = useState('')
|
const authError = new URLSearchParams(window.location.search).get('auth_error')
|
||||||
|
const [error, setError] = useState(AUTH_ERRORS[authError] || '')
|
||||||
const { login, isLoading } = useAuthStore()
|
const { login, isLoading } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
|||||||
@@ -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,59 +175,11 @@ 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: '' })
|
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
|
||||||
const [pidSaved, setPidSaved] = useState(false)
|
const [pidSaved, setPidSaved] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
|
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '', allowed_group: pocketidConfig.allowed_group || '' })
|
||||||
}, [pocketidConfig])
|
}, [pocketidConfig])
|
||||||
const savePocketID = useMutation({
|
const savePocketID = useMutation({
|
||||||
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
|
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
|
||||||
@@ -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>
|
{healthSummary?.latest?.weight_kg && (
|
||||||
</div>
|
<div>
|
||||||
)}
|
<p className="text-xs text-gray-500 mb-0.5">Current weight (from Garmin)</p>
|
||||||
{healthSummary?.latest?.weight_kg && (
|
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Weight (from Garmin)</p>
|
)}
|
||||||
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
|
</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,11 +423,19 @@ export default function ProfilePage() {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div className="h-2 flex-1 bg-gray-800 rounded-full overflow-hidden">
|
||||||
className="h-full bg-blue-500 rounded-full transition-all duration-700"
|
<div
|
||||||
style={{ width: `${pct}%` }}
|
className="h-full bg-blue-500 rounded-full transition-all duration-700"
|
||||||
/>
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={cancelSync}
|
||||||
|
disabled={status.startsWith('Cancel')}
|
||||||
|
className="text-red-400 hover:text-red-300 disabled:opacity-50 text-xs font-medium px-2 py-1 rounded-lg border border-red-500/40 hover:border-red-400 transition-colors whitespace-nowrap">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-blue-400">
|
<p className="text-xs text-blue-400">
|
||||||
{status || 'Starting sync…'}
|
{status || 'Starting sync…'}
|
||||||
@@ -471,6 +464,10 @@ export default function ProfilePage() {
|
|||||||
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
|
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
|
||||||
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
|
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Allowed PocketID group" hint="Only members of this PocketID group may sign in. Leave blank to allow all.">
|
||||||
|
<Input value={pidForm.allowed_group} placeholder="e.g. milevault-users"
|
||||||
|
onChange={e => setPidForm(f => ({ ...f, allowed_group: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
{pocketidConfig?.enabled && (
|
{pocketidConfig?.enabled && (
|
||||||
<p className="text-xs text-green-400">✓ PocketID is currently active</p>
|
<p className="text-xs text-green-400">✓ PocketID is currently active</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
{/* Route tile grid */}
|
<table className="w-full text-sm">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
<thead>
|
||||||
{routes.map(r => (
|
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||||
<button
|
<th className="px-3 py-3" />
|
||||||
key={r.id}
|
<th className="text-left px-3 py-3 font-medium">Segment</th>
|
||||||
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
|
<th className="text-right px-3 py-3 font-medium">Distance</th>
|
||||||
className={`text-left rounded-xl border p-2 transition-colors ${
|
<th className="text-right px-3 py-3 font-medium">Best time</th>
|
||||||
selectedRouteId === r.id
|
<th className="text-right px-3 py-3 font-medium">Efforts</th>
|
||||||
? 'border-blue-500 bg-blue-900/20'
|
</tr>
|
||||||
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
|
</thead>
|
||||||
}`}
|
<tbody>
|
||||||
>
|
{segments.map(seg => (
|
||||||
<RouteMiniMap
|
<Fragment key={seg.id}>
|
||||||
polyline={r.reference_polyline}
|
<tr
|
||||||
sportType={r.sport_type}
|
onClick={() => setOpen(open === seg.id ? null : seg.id)}
|
||||||
width="100%"
|
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
||||||
height={80}
|
open === seg.id ? 'bg-blue-900/20' : 'hover:bg-gray-800/40'
|
||||||
/>
|
}`}
|
||||||
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
|
>
|
||||||
{r.distance_m && (
|
<td className="px-3 py-2">
|
||||||
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
|
<RouteMiniMap polyline={seg.polyline} sportType={seg.sport_type} width={72} height={50} />
|
||||||
)}
|
</td>
|
||||||
</button>
|
<td className="px-3 py-3 font-medium text-white">
|
||||||
))}
|
{seg.sport_type && <span className="capitalize text-xs text-gray-500 mr-2">{seg.sport_type}</span>}
|
||||||
</div>
|
{seg.name}
|
||||||
|
</td>
|
||||||
{selectedRouteId && (
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
isLoading ? (
|
{formatDistance(seg.distance_m)}
|
||||||
<p className="text-gray-500 text-sm">Loading…</p>
|
</td>
|
||||||
) : !bests?.length ? (
|
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||||
<p className="text-gray-600 text-sm">
|
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
||||||
No segments for this route.{' '}
|
</td>
|
||||||
<Link to="/segments" className="text-blue-400 hover:underline">Create some on the Segments page.</Link>
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
</p>
|
{seg.effort_count}
|
||||||
) : (
|
</td>
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
</tr>
|
||||||
<table className="w-full text-sm">
|
{open === seg.id && (
|
||||||
<thead>
|
<tr>
|
||||||
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
<td colSpan={5} className="p-0 border-b border-gray-800/50">
|
||||||
<th className="text-left px-4 py-3 font-medium">Segment</th>
|
<SegmentLeaderboard segmentId={seg.id} />
|
||||||
<th className="text-right px-4 py-3 font-medium">Length</th>
|
</td>
|
||||||
<th className="text-right px-4 py-3 font-medium">Best time</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium">Runs</th>
|
|
||||||
<th className="px-4 py-3" />
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
)}
|
||||||
<tbody>
|
</Fragment>
|
||||||
{bests.map(b => (
|
))}
|
||||||
<tr key={b.segment_id} className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors">
|
</tbody>
|
||||||
<td className="px-4 py-3 text-gray-200">
|
</table>
|
||||||
{b.name}
|
|
||||||
{b.auto_generated && <span className="ml-2 text-xs text-gray-600">(auto)</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-gray-500 text-xs">
|
|
||||||
{formatDistance(b.end_distance_m - b.start_distance_m)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right font-mono font-semibold">
|
|
||||||
{b.best_s != null
|
|
||||||
? <span className="text-yellow-400">{formatDuration(b.best_s)}</span>
|
|
||||||
: <span className="text-gray-700">--</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-gray-500 text-xs">{b.count}</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
{b.best_activity_id && (
|
|
||||||
<Link to={`/activities/${b.best_activity_id}`} className="text-xs text-blue-400 hover:underline">
|
|
||||||
View →
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,17 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
|||||||
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
|
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
|
||||||
clearInterval(intervalsRef.current[taskId])
|
clearInterval(intervalsRef.current[taskId])
|
||||||
delete intervalsRef.current[taskId]
|
delete intervalsRef.current[taskId]
|
||||||
|
// A successful task may still have skipped the file (e.g. a duplicate or
|
||||||
|
// an activity that looks like vehicle travel) — surface the reason.
|
||||||
|
const skipped = data.status === 'SUCCESS' && data.result?.status === 'skipped'
|
||||||
setTasks(ts => ts.map(t =>
|
setTasks(ts => ts.map(t =>
|
||||||
t.task_id === taskId ? { ...t, status: data.status === 'SUCCESS' ? 'done' : 'failed' } : t
|
t.task_id === taskId
|
||||||
|
? { ...t,
|
||||||
|
status: data.status === 'FAILURE' ? 'failed' : skipped ? 'skipped' : 'done',
|
||||||
|
reason: skipped ? data.result?.reason : t.reason }
|
||||||
|
: t
|
||||||
))
|
))
|
||||||
if (data.status === 'SUCCESS') {
|
if (data.status === 'SUCCESS' && !skipped) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['activities'] })
|
queryClient.invalidateQueries({ queryKey: ['activities'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['health-summary'] })
|
queryClient.invalidateQueries({ queryKey: ['health-summary'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
|
queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
|
||||||
@@ -50,6 +57,10 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
|||||||
pollTask(data.task_id)
|
pollTask(data.task_id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onError: (err, file) => {
|
||||||
|
const reason = err.response?.data?.detail || 'Upload failed'
|
||||||
|
setTasks(t => [...t, { file: file?.name || String(file), status: 'failed', reason }])
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const onDrop = useCallback((accepted) => {
|
const onDrop = useCallback((accepted) => {
|
||||||
@@ -65,6 +76,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
|||||||
function StatusBadge({ status }) {
|
function StatusBadge({ status }) {
|
||||||
if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse">⏳ Processing</span>
|
if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse">⏳ Processing</span>
|
||||||
if (status === 'done') return <span className="ml-2 text-green-400">✓ Done</span>
|
if (status === 'done') return <span className="ml-2 text-green-400">✓ Done</span>
|
||||||
|
if (status === 'skipped') return <span className="ml-2 text-amber-400">⚠ Skipped</span>
|
||||||
if (status === 'failed') return <span className="ml-2 text-red-400">✗ Failed</span>
|
if (status === 'failed') return <span className="ml-2 text-red-400">✗ Failed</span>
|
||||||
return <span className="ml-2 text-green-400">✓ Queued</span>
|
return <span className="ml-2 text-green-400">✓ Queued</span>
|
||||||
}
|
}
|
||||||
@@ -107,12 +119,19 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
|||||||
{tasks.length > 0 && (
|
{tasks.length > 0 && (
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
{tasks.map((task, i) => (
|
{tasks.map((task, i) => (
|
||||||
<div key={i} className="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2">
|
<div key={i} className="bg-gray-800 rounded-lg px-3 py-2">
|
||||||
<span className="text-gray-300 truncate flex-1">{task.file}</span>
|
<div className="flex items-center justify-between text-xs">
|
||||||
{task.activity_tasks !== undefined && (
|
<span className="text-gray-300 truncate flex-1">{task.file}</span>
|
||||||
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
|
{task.activity_tasks !== undefined && (
|
||||||
|
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
|
||||||
|
)}
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</div>
|
||||||
|
{task.reason && (task.status === 'skipped' || task.status === 'failed') && (
|
||||||
|
<p className={`text-xs mt-1 ${task.status === 'skipped' ? 'text-amber-400/80' : 'text-red-400/80'}`}>
|
||||||
|
{task.reason}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import { useAuthStore } from '../hooks/useAuth'
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { user: me } = useAuthStore()
|
||||||
|
|
||||||
|
const { data: users, isLoading } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: () => api.get('/users/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const setAdmin = useMutation({
|
||||||
|
mutationFn: ({ id, is_admin }) => api.patch(`/users/${id}`, { is_admin }).then(r => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||||
|
onError: e => alert(e.response?.data?.detail || 'Failed to update user'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteUser = useMutation({
|
||||||
|
mutationFn: id => api.delete(`/users/${id}`).then(r => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||||
|
onError: e => alert(e.response?.data?.detail || 'Failed to delete user'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = u => {
|
||||||
|
if (confirm(`Delete ${u.username} and ALL of their data (activities, routes, health, records)? This cannot be undone.`)) {
|
||||||
|
deleteUser.mutate(u.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-3xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Users</h1>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
New users are created in PocketID and provisioned automatically on first passkey sign-in.
|
||||||
|
Each user's data is fully separate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="p-5 text-sm text-gray-500">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-gray-500 border-b border-gray-800">
|
||||||
|
<th className="px-4 py-3 font-medium">User</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Sign-in</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Activities</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-center">Admin</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users?.map(u => {
|
||||||
|
const isMe = u.id === me?.id
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-b border-gray-800/60 last:border-0">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-white">@{u.username}{isMe && <span className="text-gray-500"> (you)</span>}</div>
|
||||||
|
{u.email && <div className="text-xs text-gray-500">{u.email}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">
|
||||||
|
{u.has_passkey ? '🔑 Passkey' : '🔒 Password'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-300">{u.activity_count}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={u.is_admin}
|
||||||
|
disabled={isMe || setAdmin.isPending}
|
||||||
|
onChange={e => setAdmin.mutate({ id: u.id, is_admin: e.target.checked })}
|
||||||
|
className="w-4 h-4 accent-blue-500 disabled:opacity-40"
|
||||||
|
title={isMe ? "You can't change your own admin status" : ''}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u)}
|
||||||
|
disabled={isMe || deleteUser.isPending}
|
||||||
|
className="text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs transition-colors"
|
||||||
|
title={isMe ? "You can't delete your own account" : ''}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -32,3 +32,5 @@ VITE_MAPBOX_TOKEN=
|
|||||||
POCKETID_ISSUER=
|
POCKETID_ISSUER=
|
||||||
POCKETID_CLIENT_ID=
|
POCKETID_CLIENT_ID=
|
||||||
POCKETID_CLIENT_SECRET=
|
POCKETID_CLIENT_SECRET=
|
||||||
|
# Restrict sign-in to members of this PocketID group (leave blank to allow all)
|
||||||
|
POCKETID_ALLOWED_GROUP=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, desc
|
from sqlalchemy import select, func, desc, delete
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -75,6 +75,30 @@ class LapOut(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/ytd")
|
||||||
|
async def ytd_stats(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return year-to-date distance totals grouped by sport type."""
|
||||||
|
from datetime import date, timezone
|
||||||
|
year_start = datetime(date.today().year, 1, 1, tzinfo=timezone.utc)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Activity.sport_type, func.sum(Activity.distance_m).label("total_m"))
|
||||||
|
.where(Activity.user_id == current_user.id, Activity.start_time >= year_start)
|
||||||
|
.group_by(Activity.sport_type)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
totals = {r.sport_type: (r.total_m or 0) / 1000 for r in rows}
|
||||||
|
return {
|
||||||
|
"running_km": round(totals.get("running", 0), 2),
|
||||||
|
"cycling_km": round(totals.get("cycling", 0), 2),
|
||||||
|
"hiking_km": round(totals.get("hiking", 0), 2),
|
||||||
|
"walking_km": round(totals.get("walking", 0), 2),
|
||||||
|
"total_km": round(sum(totals.values()), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[ActivitySummary])
|
@router.get("/", response_model=List[ActivitySummary])
|
||||||
async def list_activities(
|
async def list_activities(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -126,7 +150,6 @@ async def get_data_points(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
# Verify ownership
|
|
||||||
act = await db.execute(
|
act = await db.execute(
|
||||||
select(Activity).where(
|
select(Activity).where(
|
||||||
Activity.id == activity_id,
|
Activity.id == activity_id,
|
||||||
@@ -211,3 +234,38 @@ async def delete_activity(
|
|||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
await db.delete(activity)
|
await db.delete(activity)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{activity_id}/reprocess")
|
||||||
|
async def reprocess_activity(
|
||||||
|
activity_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Re-parse the source FIT file and update polyline, data points etc."""
|
||||||
|
import os
|
||||||
|
result = await db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.id == activity_id,
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
activity = result.scalar_one_or_none()
|
||||||
|
if not activity:
|
||||||
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
if not activity.source_file:
|
||||||
|
raise HTTPException(status_code=400, detail="No source file stored for this activity")
|
||||||
|
if not os.path.exists(activity.source_file):
|
||||||
|
raise HTTPException(status_code=404, detail="Source file no longer exists on disk")
|
||||||
|
|
||||||
|
source_file = activity.source_file
|
||||||
|
source_type = activity.source_type or "fit"
|
||||||
|
|
||||||
|
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id))
|
||||||
|
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id))
|
||||||
|
await db.delete(activity)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
from app.workers.tasks import process_activity_file
|
||||||
|
task = process_activity_file.delay(source_file, current_user.id, source_type)
|
||||||
|
return {"task_id": task.id, "status": "queued"}
|
||||||
@@ -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,17 +15,85 @@ 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
|
||||||
return issuer, client_id, client_secret
|
return issuer, client_id, client_secret
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_allowed_group(db: AsyncSession):
|
||||||
|
"""Group a PocketID user must belong to in order to sign in (None = allow all)."""
|
||||||
|
admin = await _config_admin(db)
|
||||||
|
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
|
||||||
|
return (group or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
async def _unique_username(db: AsyncSession, base: str) -> str:
|
||||||
|
"""Return `base`, or `base-2`, `base-3`, … until it is not already taken."""
|
||||||
|
base = (base or "user").strip() or "user"
|
||||||
|
candidate = base
|
||||||
|
n = 1
|
||||||
|
while True:
|
||||||
|
existing = await db.execute(select(User).where(User.username == candidate))
|
||||||
|
if existing.scalar_one_or_none() is None:
|
||||||
|
return candidate
|
||||||
|
n += 1
|
||||||
|
candidate = f"{base}-{n}"
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
@@ -37,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
|
||||||
@@ -60,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")
|
||||||
@@ -77,46 +154,122 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
params = {
|
params = {
|
||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"redirect_uri": "/api/auth/pocketid/callback",
|
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": "openid profile email",
|
"scope": "openid profile email groups",
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{issuer}/token",
|
f"{issuer}/api/oidc/token",
|
||||||
data={"grant_type": "authorization_code", "code": code,
|
data={"grant_type": "authorization_code", "code": code,
|
||||||
"redirect_uri": "/api/auth/pocketid/callback",
|
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||||
"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(
|
||||||
f"{issuer}/userinfo",
|
f"{issuer}/api/oidc/userinfo",
|
||||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||||
)
|
)
|
||||||
userinfo = userinfo_resp.json()
|
userinfo = userinfo_resp.json()
|
||||||
|
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
sub = userinfo.get("sub")
|
sub = userinfo.get("sub")
|
||||||
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.
|
||||||
|
allowed_group = await _get_allowed_group(db)
|
||||||
|
if allowed_group:
|
||||||
|
groups = userinfo.get("groups") or []
|
||||||
|
if allowed_group not in groups:
|
||||||
|
return RedirectResponse(url="/login?auth_error=not_authorized")
|
||||||
|
|
||||||
|
# 1) Existing passkey identity → use it.
|
||||||
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 2) No passkey identity yet, but an account with this email exists and is
|
||||||
|
# not already linked to a different passkey → link them (preserves data).
|
||||||
|
if not user and email:
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing and existing.pocketid_sub is None:
|
||||||
|
existing.pocketid_sub = sub
|
||||||
|
user = existing
|
||||||
|
|
||||||
|
# 3) Otherwise provision a new account with a collision-safe username.
|
||||||
if not user:
|
if not user:
|
||||||
user = User(username=preferred_username, email=email, pocketid_sub=sub)
|
base = preferred_username or (email.split("@")[0] if email else "user")
|
||||||
|
username = await _unique_username(db, base)
|
||||||
|
# Only set email if no other account already claims it (unique column).
|
||||||
|
email_taken = False
|
||||||
|
if email:
|
||||||
|
dup = await db.execute(select(User).where(User.email == email))
|
||||||
|
email_taken = dup.scalar_one_or_none() is not None
|
||||||
|
user = User(username=username, email=None if email_taken else email, pocketid_sub=sub)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
token = create_access_token({"sub": str(user.id)})
|
token = create_access_token({"sub": str(user.id)})
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
return RedirectResponse(url=f"/?token={token}")
|
return RedirectResponse(url=f"/?token={token}")
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User, GarminConnectConfig
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class GarminConfigIn(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing.
|
||||||
|
sync_enabled: bool = True
|
||||||
|
sync_activities: bool = True
|
||||||
|
sync_wellness: bool = True
|
||||||
|
sync_lookback_days: int = 30 # days to look back on first sync; -1 = all-time
|
||||||
|
|
||||||
|
|
||||||
|
class GarminConfigOut(BaseModel):
|
||||||
|
email: str
|
||||||
|
sync_enabled: bool
|
||||||
|
sync_activities: bool
|
||||||
|
sync_wellness: bool
|
||||||
|
sync_lookback_days: int
|
||||||
|
last_sync_at: Optional[datetime]
|
||||||
|
last_sync_status: Optional[str]
|
||||||
|
connected: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=GarminConfigOut)
|
||||||
|
async def get_config(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
cfg = result.scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
return GarminConfigOut(
|
||||||
|
email="", sync_enabled=False, sync_activities=True,
|
||||||
|
sync_wellness=True, sync_lookback_days=30,
|
||||||
|
last_sync_at=None, last_sync_status=None, connected=False,
|
||||||
|
)
|
||||||
|
return GarminConfigOut(
|
||||||
|
email=cfg.email,
|
||||||
|
sync_enabled=cfg.sync_enabled,
|
||||||
|
sync_activities=cfg.sync_activities,
|
||||||
|
sync_wellness=cfg.sync_wellness,
|
||||||
|
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
|
||||||
|
last_sync_at=cfg.last_sync_at,
|
||||||
|
last_sync_status=cfg.last_sync_status,
|
||||||
|
connected=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config", response_model=GarminConfigOut)
|
||||||
|
async def save_config(
|
||||||
|
body: GarminConfigIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Save Garmin Connect settings. If a password is provided, re-authenticates and
|
||||||
|
refreshes the stored OAuth token. If no password is provided, only updates the
|
||||||
|
non-credential settings (toggles, lookback days) without re-logging in.
|
||||||
|
"""
|
||||||
|
from app.services.garmin_connect_sync import encrypt_password, authenticate_garmin
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
cfg = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if body.password:
|
||||||
|
# Credentials update — test-login before saving
|
||||||
|
enc = encrypt_password(body.password)
|
||||||
|
try:
|
||||||
|
garmin, token_store = authenticate_garmin(body.email, enc, None)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Garmin login failed: {exc}")
|
||||||
|
|
||||||
|
if cfg:
|
||||||
|
cfg.email = body.email
|
||||||
|
cfg.password_enc = enc
|
||||||
|
cfg.token_store = token_store
|
||||||
|
cfg.last_sync_status = "Credentials updated"
|
||||||
|
else:
|
||||||
|
cfg = GarminConnectConfig(
|
||||||
|
user_id=current_user.id,
|
||||||
|
email=body.email,
|
||||||
|
password_enc=enc,
|
||||||
|
token_store=token_store,
|
||||||
|
last_sync_status="Connected",
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
else:
|
||||||
|
# Settings-only update — password unchanged
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup")
|
||||||
|
|
||||||
|
cfg.sync_enabled = body.sync_enabled
|
||||||
|
cfg.sync_activities = body.sync_activities
|
||||||
|
cfg.sync_wellness = body.sync_wellness
|
||||||
|
cfg.sync_lookback_days = body.sync_lookback_days
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
|
||||||
|
return GarminConfigOut(
|
||||||
|
email=cfg.email,
|
||||||
|
sync_enabled=cfg.sync_enabled,
|
||||||
|
sync_activities=cfg.sync_activities,
|
||||||
|
sync_wellness=cfg.sync_wellness,
|
||||||
|
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
|
||||||
|
last_sync_at=cfg.last_sync_at,
|
||||||
|
last_sync_status=cfg.last_sync_status,
|
||||||
|
connected=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/config")
|
||||||
|
async def delete_config(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
cfg = result.scalar_one_or_none()
|
||||||
|
if cfg:
|
||||||
|
await db.delete(cfg)
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trigger")
|
||||||
|
async def trigger_sync(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Enqueue an immediate Garmin Connect sync for this user."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
cfg = result.scalar_one_or_none()
|
||||||
|
if not cfg or not cfg.sync_enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="Garmin Connect sync is not configured or disabled")
|
||||||
|
|
||||||
|
from app.workers.tasks import sync_garmin_connect_user
|
||||||
|
task = sync_garmin_connect_user.delay(current_user.id)
|
||||||
|
return {"task_id": task.id, "status": "queued"}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, desc, func
|
from sqlalchemy import select, desc, func
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, model_validator
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Any
|
||||||
from datetime import datetime, date
|
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
|
||||||
@@ -44,6 +44,13 @@ class HealthMetricOut(BaseModel):
|
|||||||
active_calories: Optional[float]
|
active_calories: Optional[float]
|
||||||
total_calories: Optional[float]
|
total_calories: Optional[float]
|
||||||
spo2_avg: Optional[float]
|
spo2_avg: Optional[float]
|
||||||
|
body_battery: Optional[Any] = None # {charged,drained,start_level,end_level} — values stripped
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def _strip_bb_values(self):
|
||||||
|
if isinstance(self.body_battery, dict):
|
||||||
|
self.body_battery = {k: v for k, v in self.body_battery.items() if k != 'values'}
|
||||||
|
return self
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -53,17 +60,20 @@ class HealthMetricOut(BaseModel):
|
|||||||
async def list_health_metrics(
|
async def list_health_metrics(
|
||||||
from_date: Optional[datetime] = None,
|
from_date: Optional[datetime] = None,
|
||||||
to_date: Optional[datetime] = None,
|
to_date: Optional[datetime] = None,
|
||||||
limit: int = Query(365, ge=1, le=1000),
|
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),
|
||||||
):
|
):
|
||||||
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
|
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
|
||||||
if from_date:
|
|
||||||
q = q.where(HealthMetric.date >= from_date)
|
|
||||||
if to_date:
|
|
||||||
q = q.where(HealthMetric.date <= to_date)
|
|
||||||
q = q.order_by(desc(HealthMetric.date)).limit(limit)
|
|
||||||
|
|
||||||
|
if from_date:
|
||||||
|
from_date_naive = from_date.replace(tzinfo=None) if from_date.tzinfo else from_date
|
||||||
|
q = q.where(func.date(HealthMetric.date) >= from_date_naive.date())
|
||||||
|
if to_date:
|
||||||
|
to_date_naive = to_date.replace(tzinfo=None) if to_date.tzinfo else to_date
|
||||||
|
q = q.where(func.date(HealthMetric.date) <= to_date_naive.date())
|
||||||
|
|
||||||
|
q = q.order_by(desc(HealthMetric.date)).limit(limit)
|
||||||
result = await db.execute(q)
|
result = await db.execute(q)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@@ -73,8 +83,6 @@ async def health_summary(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Latest values + 30-day averages for dashboard widgets."""
|
|
||||||
# Latest record
|
|
||||||
latest_result = await db.execute(
|
latest_result = await db.execute(
|
||||||
select(HealthMetric)
|
select(HealthMetric)
|
||||||
.where(HealthMetric.user_id == current_user.id)
|
.where(HealthMetric.user_id == current_user.id)
|
||||||
@@ -83,9 +91,7 @@ async def health_summary(
|
|||||||
)
|
)
|
||||||
latest = latest_result.scalar_one_or_none()
|
latest = latest_result.scalar_one_or_none()
|
||||||
|
|
||||||
# 30-day averages
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
|
||||||
from datetime import timedelta, timezone
|
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
|
|
||||||
avg_result = await db.execute(
|
avg_result = await db.execute(
|
||||||
select(
|
select(
|
||||||
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
|
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
|
||||||
@@ -97,7 +103,7 @@ async def health_summary(
|
|||||||
func.avg(HealthMetric.weight_kg).label("avg_weight"),
|
func.avg(HealthMetric.weight_kg).label("avg_weight"),
|
||||||
).where(
|
).where(
|
||||||
HealthMetric.user_id == current_user.id,
|
HealthMetric.user_id == current_user.id,
|
||||||
HealthMetric.date >= cutoff,
|
func.date(HealthMetric.date) >= cutoff,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
avgs = avg_result.one()
|
avgs = avg_result.one()
|
||||||
@@ -116,23 +122,48 @@ async def health_summary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/intraday")
|
||||||
|
async def intraday_health(
|
||||||
|
date: str = Query(..., description="YYYY-MM-DD"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return intraday heart rate series for a specific day."""
|
||||||
|
from datetime import date as _date
|
||||||
|
from fastapi import HTTPException
|
||||||
|
try:
|
||||||
|
metric_date = _date.fromisoformat(date)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(HealthMetric).where(
|
||||||
|
HealthMetric.user_id == current_user.id,
|
||||||
|
func.date(HealthMetric.date) == metric_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
metric = result.scalar_one_or_none()
|
||||||
|
return {
|
||||||
|
"hr_values": metric.intraday_hr if metric else None,
|
||||||
|
"body_battery": metric.body_battery if metric else None,
|
||||||
|
"body_battery_hires": metric.body_battery_hires if metric else None,
|
||||||
|
"sleep_stages": metric.sleep_stages if metric else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/manual")
|
@router.put("/manual")
|
||||||
async def add_manual_metric(
|
async def add_manual_metric(
|
||||||
body: dict,
|
body: dict,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Manually add or update a health metric for a given date."""
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
|
|
||||||
date_str = body.get("date")
|
date_str = body.get("date")
|
||||||
if not date_str:
|
if not date_str:
|
||||||
from fastapi import HTTPException
|
|
||||||
raise HTTPException(status_code=400, detail="date required")
|
raise HTTPException(status_code=400, detail="date required")
|
||||||
|
|
||||||
metric_date = datetime.fromisoformat(date_str)
|
metric_date = datetime.fromisoformat(date_str)
|
||||||
|
|
||||||
# Check for existing
|
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
select(HealthMetric).where(
|
select(HealthMetric).where(
|
||||||
HealthMetric.user_id == current_user.id,
|
HealthMetric.user_id == current_user.id,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class ProfileUpdate(BaseModel):
|
|||||||
resting_heart_rate: Optional[int] = None
|
resting_heart_rate: Optional[int] = None
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
class ProfileOut(BaseModel):
|
class ProfileOut(BaseModel):
|
||||||
@@ -29,6 +30,7 @@ class ProfileOut(BaseModel):
|
|||||||
resting_heart_rate: Optional[int]
|
resting_heart_rate: Optional[int]
|
||||||
birth_year: Optional[int]
|
birth_year: Optional[int]
|
||||||
height_cm: Optional[float]
|
height_cm: Optional[float]
|
||||||
|
biological_sex: Optional[str]
|
||||||
estimated_max_hr: Optional[int]
|
estimated_max_hr: Optional[int]
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ async def update_profile(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
old_max_hr = current_user.max_heart_rate
|
||||||
if body.max_heart_rate is not None:
|
if body.max_heart_rate is not None:
|
||||||
if not (100 <= body.max_heart_rate <= 250):
|
if not (100 <= body.max_heart_rate <= 250):
|
||||||
raise HTTPException(400, "Max HR must be 100–250")
|
raise HTTPException(400, "Max HR must be 100–250")
|
||||||
@@ -71,9 +74,18 @@ async def update_profile(
|
|||||||
if not (50 <= body.height_cm <= 300):
|
if not (50 <= body.height_cm <= 300):
|
||||||
raise HTTPException(400, "Height must be 50–300 cm")
|
raise HTTPException(400, "Height must be 50–300 cm")
|
||||||
current_user.height_cm = body.height_cm
|
current_user.height_cm = body.height_cm
|
||||||
|
if body.biological_sex is not None:
|
||||||
|
if body.biological_sex not in ('male', 'female', ''):
|
||||||
|
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
|
||||||
|
current_user.biological_sex = body.biological_sex or None
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
|
|
||||||
|
if body.max_heart_rate is not None and body.max_heart_rate != old_max_hr:
|
||||||
|
from app.workers.tasks import recalculate_hr_zones_for_user
|
||||||
|
recalculate_hr_zones_for_user.delay(current_user.id, body.max_heart_rate)
|
||||||
|
|
||||||
return {**{c.name: getattr(current_user, c.name)
|
return {**{c.name: getattr(current_user, c.name)
|
||||||
for c in User.__table__.columns},
|
for c in User.__table__.columns},
|
||||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||||||
@@ -109,6 +121,7 @@ class PocketIDConfig(BaseModel):
|
|||||||
issuer: Optional[str] = None
|
issuer: Optional[str] = None
|
||||||
client_id: Optional[str] = None
|
client_id: Optional[str] = None
|
||||||
client_secret: Optional[str] = None
|
client_secret: Optional[str] = None
|
||||||
|
allowed_group: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pocketid-config")
|
@router.get("/pocketid-config")
|
||||||
@@ -119,10 +132,12 @@ async def get_pocketid_config(current_user: User = Depends(get_current_user)):
|
|||||||
# Show DB config if set, fall back to env
|
# Show DB config if set, fall back to env
|
||||||
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
|
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
|
||||||
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
|
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
|
||||||
|
allowed_group = current_user.pocketid_allowed_group or settings.pocketid_allowed_group
|
||||||
return {
|
return {
|
||||||
"issuer": issuer or "",
|
"issuer": issuer or "",
|
||||||
"client_id": client_id or "",
|
"client_id": client_id or "",
|
||||||
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
|
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
|
||||||
|
"allowed_group": allowed_group or "",
|
||||||
"enabled": bool(issuer and client_id),
|
"enabled": bool(issuer and client_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,8 +154,12 @@ 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:
|
||||||
|
current_user.pocketid_allowed_group = body.allowed_group.strip() or None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,36 @@ async def list_records(
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/routes")
|
||||||
|
async def route_records(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Fastest activity per named route (course records)."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
rows = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT DISTINCT ON (nr.id)
|
||||||
|
nr.id AS route_id,
|
||||||
|
nr.name AS route_name,
|
||||||
|
nr.sport_type,
|
||||||
|
nr.distance_m,
|
||||||
|
nr.reference_polyline,
|
||||||
|
a.id AS activity_id,
|
||||||
|
a.name AS activity_name,
|
||||||
|
a.duration_s,
|
||||||
|
a.start_time,
|
||||||
|
a.avg_speed_ms
|
||||||
|
FROM named_routes nr
|
||||||
|
JOIN activities a ON a.named_route_id = nr.id AND a.user_id = nr.user_id
|
||||||
|
WHERE nr.user_id = :uid AND a.duration_s IS NOT NULL
|
||||||
|
ORDER BY nr.id, a.duration_s ASC
|
||||||
|
"""),
|
||||||
|
{"uid": current_user.id},
|
||||||
|
)
|
||||||
|
return [dict(r._mapping) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/history/{distance_label}")
|
@router.get("/history/{distance_label}")
|
||||||
async def record_history(
|
async def record_history(
|
||||||
distance_label: str,
|
distance_label: str,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc, func
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -36,6 +36,7 @@ class RouteOut(BaseModel):
|
|||||||
distance_m: Optional[float]
|
distance_m: Optional[float]
|
||||||
auto_detected: Optional[bool]
|
auto_detected: Optional[bool]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
activity_count: int = 0
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -47,22 +48,51 @@ class SegmentOut(BaseModel):
|
|||||||
start_distance_m: float
|
start_distance_m: float
|
||||||
end_distance_m: float
|
end_distance_m: float
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
|
auto_generated: Optional[bool] = False
|
||||||
|
auto_generated_type: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
# Fetch routes with activity counts in one query
|
||||||
|
count_subq = (
|
||||||
|
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
|
||||||
|
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
|
||||||
|
.group_by(Activity.named_route_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(NamedRoute)
|
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
|
||||||
|
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
|
||||||
.where(NamedRoute.user_id == current_user.id)
|
.where(NamedRoute.user_id == current_user.id)
|
||||||
.order_by(desc(NamedRoute.created_at))
|
.order_by(desc(NamedRoute.created_at))
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
rows = result.all()
|
||||||
|
out = []
|
||||||
|
for route, cnt in rows:
|
||||||
|
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
|
||||||
|
d["activity_count"] = cnt
|
||||||
|
out.append(RouteOut(**d))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recent-activities")
|
@router.get("/recent-activities")
|
||||||
@@ -176,6 +206,61 @@ async def route_activities(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{route_id}/merge/{source_id}", response_model=RouteOut)
|
||||||
|
async def merge_routes(
|
||||||
|
route_id: int,
|
||||||
|
source_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Move all activities from source route into route_id, then delete source route."""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
target = (await db.execute(
|
||||||
|
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
source = (await db.execute(
|
||||||
|
select(NamedRoute).where(NamedRoute.id == source_id, NamedRoute.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not target or not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Route not found")
|
||||||
|
if route_id == source_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot merge a route with itself")
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(Activity)
|
||||||
|
.where(Activity.named_route_id == source_id, Activity.user_id == current_user.id)
|
||||||
|
.values(named_route_id=route_id)
|
||||||
|
)
|
||||||
|
await db.delete(source)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{route_id}")
|
||||||
|
async def delete_route(
|
||||||
|
route_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from sqlalchemy import update as sa_update
|
||||||
|
route = (await db.execute(
|
||||||
|
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not route:
|
||||||
|
raise HTTPException(status_code=404, detail="Route not found")
|
||||||
|
# Unlink activities before deleting
|
||||||
|
await db.execute(
|
||||||
|
sa_update(Activity)
|
||||||
|
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
||||||
|
.values(named_route_id=None)
|
||||||
|
)
|
||||||
|
await db.delete(route)
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{route_id}/assign-activity")
|
@router.post("/{route_id}/assign-activity")
|
||||||
async def assign_activity_to_route(
|
async def assign_activity_to_route(
|
||||||
route_id: int,
|
route_id: int,
|
||||||
@@ -198,12 +283,23 @@ async def assign_activity_to_route(
|
|||||||
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])
|
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
|
||||||
async def list_segments(
|
async def list_segments(
|
||||||
route_id: int,
|
route_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
await _get_owned_route(route_id, current_user.id, db)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RouteSegment)
|
select(RouteSegment)
|
||||||
.where(RouteSegment.route_id == route_id)
|
.where(RouteSegment.route_id == route_id)
|
||||||
@@ -219,14 +315,258 @@ async def create_segment(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
await _get_owned_route(route_id, current_user.id, db)
|
||||||
segment = RouteSegment(
|
segment = RouteSegment(
|
||||||
route_id=route_id,
|
route_id=route_id,
|
||||||
name=body.name,
|
name=body.name,
|
||||||
start_distance_m=body.start_distance_m,
|
start_distance_m=body.start_distance_m,
|
||||||
end_distance_m=body.end_distance_m,
|
end_distance_m=body.end_distance_m,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
|
auto_generated=False,
|
||||||
)
|
)
|
||||||
db.add(segment)
|
db.add(segment)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(segment)
|
await db.refresh(segment)
|
||||||
return 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
|
||||||
|
|||||||
@@ -75,6 +75,22 @@ async def upload_garmin_export(
|
|||||||
fit_path = extract_dir / name
|
fit_path = extract_dir / name
|
||||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
||||||
task_ids.append(task.id)
|
task_ids.append(task.id)
|
||||||
|
elif lower.endswith(".zip"):
|
||||||
|
# Garmin exports nest activity FIT files inside sub-zips
|
||||||
|
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
|
||||||
|
nested_zip_path = extract_dir / name
|
||||||
|
nested_extract = nested_zip_path.parent / nested_zip_path.stem
|
||||||
|
nested_extract.mkdir(exist_ok=True)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(nested_zip_path) as nzf:
|
||||||
|
nzf.extractall(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:
|
||||||
|
pass
|
||||||
|
|
||||||
# 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)
|
||||||
@@ -82,7 +98,7 @@ async def upload_garmin_export(
|
|||||||
return {
|
return {
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"activity_tasks": len(task_ids),
|
"activity_tasks": len(task_ids),
|
||||||
"health_task": health_task.id,
|
"task_id": health_task.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +132,7 @@ async def upload_strava_export(
|
|||||||
return {
|
return {
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"activity_tasks": len(task_ids),
|
"activity_tasks": len(task_ids),
|
||||||
|
"task_id": task_ids[-1] if task_ids else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
Admin-only user management: list provisioned users, promote/demote admin,
|
||||||
|
and delete a user together with all of their data.
|
||||||
|
|
||||||
|
New users are normally provisioned just-in-time on first PocketID login
|
||||||
|
(see app/api/auth.py). This router is the in-app surface for managing them.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, delete, func
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.user import (
|
||||||
|
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
|
||||||
|
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(current_user: User):
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(403, "Admin only")
|
||||||
|
|
||||||
|
|
||||||
|
async def _admin_count(db: AsyncSession) -> int:
|
||||||
|
result = await db.execute(select(func.count()).select_from(User).where(User.is_admin == True))
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: Optional[str]
|
||||||
|
is_admin: bool
|
||||||
|
has_passkey: bool
|
||||||
|
activity_count: int
|
||||||
|
created_at: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUpdate(BaseModel):
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_users(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_admin(current_user)
|
||||||
|
# activity counts per user in one grouped query
|
||||||
|
counts = dict(
|
||||||
|
(await db.execute(
|
||||||
|
select(Activity.user_id, func.count(Activity.id)).group_by(Activity.user_id)
|
||||||
|
)).all()
|
||||||
|
)
|
||||||
|
result = await db.execute(select(User).order_by(User.id))
|
||||||
|
users = result.scalars().all()
|
||||||
|
return [
|
||||||
|
UserOut(
|
||||||
|
id=u.id,
|
||||||
|
username=u.username,
|
||||||
|
email=u.email,
|
||||||
|
is_admin=u.is_admin,
|
||||||
|
has_passkey=u.pocketid_sub is not None,
|
||||||
|
activity_count=counts.get(u.id, 0),
|
||||||
|
created_at=u.created_at.isoformat() if u.created_at else None,
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}")
|
||||||
|
async def set_admin(
|
||||||
|
user_id: int,
|
||||||
|
body: AdminUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_admin(current_user)
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(400, "You cannot change your own admin status")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
|
||||||
|
# Demoting the last remaining admin would lock everyone out.
|
||||||
|
if user.is_admin and not body.is_admin and await _admin_count(db) <= 1:
|
||||||
|
raise HTTPException(400, "Cannot demote the last admin")
|
||||||
|
|
||||||
|
user.is_admin = body.is_admin
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok", "is_admin": user.is_admin}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_admin(current_user)
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(400, "You cannot delete your own account")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
if user.is_admin and await _admin_count(db) <= 1:
|
||||||
|
raise HTTPException(400, "Cannot delete the last admin")
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
activity_ids = select(Activity.id).where(Activity.user_id == user_id)
|
||||||
|
route_ids = select(NamedRoute.id).where(NamedRoute.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(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
|
||||||
|
await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids)))
|
||||||
|
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(HealthMetric).where(HealthMetric.user_id == user_id))
|
||||||
|
await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id))
|
||||||
|
await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id))
|
||||||
|
await db.execute(delete(User).where(User.id == user_id))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Remove the user's uploaded files from disk (best-effort).
|
||||||
|
shutil.rmtree(Path(settings.file_store_path) / str(user_id), ignore_errors=True)
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -6,28 +6,24 @@ from typing import Optional
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
# Database
|
# Database
|
||||||
database_url: str = Field(..., env="DATABASE_URL")
|
database_url: str = Field(..., env="DATABASE_URL")
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
|
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
secret_key: str = Field(..., env="SECRET_KEY")
|
secret_key: str = Field(..., env="SECRET_KEY")
|
||||||
algorithm: str = "HS256"
|
algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||||
|
# Admin account
|
||||||
# Admin account - optional so the worker (which doesn't seed users) can start
|
|
||||||
# without it. The backend service checks this at seed time.
|
|
||||||
admin_username: str = Field("admin", env="ADMIN_USERNAME")
|
admin_username: str = Field("admin", env="ADMIN_USERNAME")
|
||||||
admin_password: Optional[str] = Field(None, env="ADMIN_PASSWORD")
|
admin_password: Optional[str] = Field(None, env="ADMIN_PASSWORD")
|
||||||
|
# Base URL - used for OAuth callbacks
|
||||||
|
base_url: str = Field("https://milevault.jarrett.eu", env="BASE_URL")
|
||||||
# PocketID OIDC (optional)
|
# PocketID OIDC (optional)
|
||||||
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
|
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
|
||||||
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")
|
||||||
# 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
|
||||||
environment: str = Field("production", env="ENVIRONMENT")
|
environment: str = Field("production", env="ENVIRONMENT")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
@@ -40,6 +40,131 @@ async def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"TimescaleDB hypertable skipped: {e}")
|
print(f"TimescaleDB hypertable skipped: {e}")
|
||||||
|
|
||||||
|
# Add columns that were introduced after initial table creation (non-fatal)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE garmin_connect_configs "
|
||||||
|
"ADD COLUMN IF NOT EXISTS sync_lookback_days INTEGER DEFAULT 30"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Column migration skipped: {e}")
|
||||||
|
|
||||||
|
# health_metrics columns added after initial creation
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
for stmt in [
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS avg_hr_day FLOAT",
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT",
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON",
|
||||||
|
]:
|
||||||
|
await conn.execute(text(stmt))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"health_metrics column migration skipped: {e}")
|
||||||
|
|
||||||
|
# biological_sex column on users added after initial creation
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS biological_sex VARCHAR(8)"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"users.biological_sex column migration skipped: {e}")
|
||||||
|
|
||||||
|
# pocketid_allowed_group column on users added after initial creation
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS pocketid_allowed_group VARCHAR(128)"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"users.pocketid_allowed_group column migration skipped: {e}")
|
||||||
|
|
||||||
|
# route_segments auto_generated column added after initial creation
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE"
|
||||||
|
))
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"route_segments column migration skipped: {e}")
|
||||||
|
|
||||||
|
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text("""
|
||||||
|
UPDATE health_metrics SET
|
||||||
|
avg_hr_day = sub.avg_hr,
|
||||||
|
max_hr_day = sub.max_hr
|
||||||
|
FROM (
|
||||||
|
SELECT id,
|
||||||
|
AVG((elem->>1)::float) AS avg_hr,
|
||||||
|
MAX((elem->>1)::float) AS max_hr
|
||||||
|
FROM health_metrics,
|
||||||
|
json_array_elements(intraday_hr) AS elem
|
||||||
|
WHERE (avg_hr_day IS NULL OR max_hr_day IS NULL)
|
||||||
|
AND intraday_hr IS NOT NULL
|
||||||
|
AND (elem->>1)::float > 0
|
||||||
|
GROUP BY id
|
||||||
|
) sub
|
||||||
|
WHERE health_metrics.id = sub.id
|
||||||
|
"""))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"avg_hr_day backfill skipped: {e}")
|
||||||
|
|
||||||
|
# Replace the all-columns unique constraint on personal_records with a partial
|
||||||
|
# index (only current records must be unique per user/sport/distance).
|
||||||
|
# The old constraint also covered is_current_record=False rows, causing
|
||||||
|
# UniqueViolation crashes when multiple workers deactivate the same PR.
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE personal_records "
|
||||||
|
"DROP CONSTRAINT IF EXISTS uq_pr_current"
|
||||||
|
))
|
||||||
|
await conn.execute(text(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_pr_current_active "
|
||||||
|
"ON personal_records (user_id, sport_type, distance_m) "
|
||||||
|
"WHERE is_current_record = true"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"PR constraint migration skipped: {e}")
|
||||||
|
|
||||||
|
# Ensure named_route_id FK has ON DELETE SET NULL so routes can be deleted
|
||||||
|
# without first manually unlinking every activity.
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE activities "
|
||||||
|
"DROP CONSTRAINT IF EXISTS activities_named_route_id_fkey"
|
||||||
|
))
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE activities "
|
||||||
|
"ADD CONSTRAINT activities_named_route_id_fkey "
|
||||||
|
"FOREIGN KEY (named_route_id) REFERENCES named_routes(id) ON DELETE SET NULL"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FK migration skipped: {e}")
|
||||||
|
|
||||||
|
# Fix avg_speed_ms stored as the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"UPDATE activities SET avg_speed_ms = distance_m / duration_s "
|
||||||
|
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
|
||||||
|
))
|
||||||
|
await conn.execute(text(
|
||||||
|
"UPDATE activity_laps SET avg_speed_ms = distance_m / duration_s "
|
||||||
|
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"avg_speed_ms fix skipped: {e}")
|
||||||
|
|
||||||
# Seed admin user (only if password is configured)
|
# Seed admin user (only if password is configured)
|
||||||
if not settings.admin_password:
|
if not settings.admin_password:
|
||||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||||
@@ -98,6 +223,8 @@ app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
|
|||||||
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||||
|
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
||||||
|
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, Float, DateTime, Boolean,
|
Column, Integer, String, Float, DateTime, Boolean,
|
||||||
ForeignKey, Text, JSON, Index, UniqueConstraint
|
ForeignKey, Text, JSON, Index, UniqueConstraint, text
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -27,16 +27,40 @@ class User(Base):
|
|||||||
resting_heart_rate = Column(Integer, nullable=True)
|
resting_heart_rate = Column(Integer, nullable=True)
|
||||||
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'
|
||||||
|
|
||||||
# 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)
|
||||||
pocketid_client_id = Column(String(256), nullable=True)
|
pocketid_client_id = Column(String(256), nullable=True)
|
||||||
pocketid_client_secret = Column(String(256), nullable=True)
|
pocketid_client_secret = Column(String(256), nullable=True)
|
||||||
|
# Only PocketID users in this group may sign in. Null/blank = allow all.
|
||||||
|
pocketid_allowed_group = Column(String(128), nullable=True)
|
||||||
|
|
||||||
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||||
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||||
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||||
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class GarminConnectConfig(Base):
|
||||||
|
"""Per-user Garmin Connect credentials and sync state."""
|
||||||
|
__tablename__ = "garmin_connect_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True)
|
||||||
|
email = Column(String(256), nullable=False)
|
||||||
|
password_enc = Column(String(512), nullable=False) # Fernet-encrypted
|
||||||
|
token_store = Column(Text, nullable=True) # garth OAuth2 token JSON
|
||||||
|
sync_enabled = Column(Boolean, default=True)
|
||||||
|
sync_activities = Column(Boolean, default=True)
|
||||||
|
sync_wellness = Column(Boolean, default=True)
|
||||||
|
sync_lookback_days = Column(Integer, default=30) # -1 = all-time
|
||||||
|
last_sync_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_sync_status = Column(String(512), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="garmin_connect_config")
|
||||||
|
|
||||||
|
|
||||||
class WeightLog(Base):
|
class WeightLog(Base):
|
||||||
@@ -81,7 +105,7 @@ class Activity(Base):
|
|||||||
calories = Column(Float, nullable=True)
|
calories = Column(Float, nullable=True)
|
||||||
training_stress_score = Column(Float, nullable=True)
|
training_stress_score = Column(Float, nullable=True)
|
||||||
vo2max_estimate = Column(Float, nullable=True)
|
vo2max_estimate = Column(Float, nullable=True)
|
||||||
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
|
named_route_id = Column(Integer, ForeignKey("named_routes.id", ondelete="SET NULL"), nullable=True)
|
||||||
polyline = Column(Text, nullable=True)
|
polyline = Column(Text, nullable=True)
|
||||||
bounding_box = Column(JSON, nullable=True)
|
bounding_box = Column(JSON, nullable=True)
|
||||||
source_file = Column(String(512), nullable=True)
|
source_file = Column(String(512), nullable=True)
|
||||||
@@ -160,6 +184,8 @@ class RouteSegment(Base):
|
|||||||
start_distance_m = Column(Float, nullable=False)
|
start_distance_m = Column(Float, nullable=False)
|
||||||
end_distance_m = Column(Float, nullable=False)
|
end_distance_m = Column(Float, nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
|
auto_generated = Column(Boolean, default=False)
|
||||||
|
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills'
|
||||||
|
|
||||||
route = relationship("NamedRoute", back_populates="segments")
|
route = relationship("NamedRoute", back_populates="segments")
|
||||||
|
|
||||||
@@ -178,8 +204,12 @@ class PersonalRecord(Base):
|
|||||||
is_current_record = Column(Boolean, default=True)
|
is_current_record = Column(Boolean, default=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
|
# Uniqueness is enforced at runtime by the partial index uq_pr_current_active
|
||||||
name="uq_pr_current"),
|
# (created in init_db), which only covers is_current_record=true rows.
|
||||||
|
# The old all-columns UniqueConstraint was dropped because it incorrectly
|
||||||
|
# constrained is_current_record=false rows too, causing multi-worker races.
|
||||||
|
Index("uq_pr_current_active", "user_id", "sport_type", "distance_m",
|
||||||
|
postgresql_where=text("is_current_record = true"), unique=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -218,6 +248,10 @@ class HealthMetric(Base):
|
|||||||
active_calories = Column(Float, nullable=True)
|
active_calories = Column(Float, nullable=True)
|
||||||
total_calories = Column(Float, nullable=True)
|
total_calories = Column(Float, nullable=True)
|
||||||
spo2_avg = Column(Float, nullable=True)
|
spo2_avg = Column(Float, nullable=True)
|
||||||
|
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
|
||||||
|
body_battery = Column(JSON, nullable=True) # {charged,drained,start_level,end_level,values:[[ts_ms,level,type,stress]...]}
|
||||||
|
body_battery_hires = Column(JSON, nullable=True) # [[ts_ms, level], ...] interpolated from bb + HR; higher resolution than raw values
|
||||||
|
sleep_stages = Column(JSON, nullable=True) # [[ts_ms, level], ...] 0=unmeasurable,1=awake,2=light,3=deep,4=rem
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
FIT and GPX file parser using:
|
FIT and GPX file parser.
|
||||||
- Official Garmin FIT Python SDK (garmin-fit-sdk) for .fit files
|
Parses FIT files directly using the Garmin SDK but applies manual
|
||||||
- gpxpy for .gpx files
|
scale conversion for fields where the SDK doesn't auto-convert.
|
||||||
|
|
||||||
The official SDK correctly handles scale/offset, component expansion,
|
|
||||||
semicircle-to-degree conversion, and HR message merging.
|
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
import struct
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import gpxpy
|
import gpxpy
|
||||||
import polyline as polyline_lib
|
import polyline as polyline_lib
|
||||||
|
from garmin_fit_sdk import Decoder, Stream
|
||||||
|
|
||||||
FIT_EPOCH_S = 631065600
|
FIT_EPOCH_S = 631065600
|
||||||
|
SEMICIRCLES_TO_DEG = 180.0 / (2 ** 31)
|
||||||
|
|
||||||
|
|
||||||
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
def _semicircles_to_deg(val):
|
||||||
"""Distance in metres between two GPS points."""
|
if val is None:
|
||||||
R = 6371000
|
return None
|
||||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
try:
|
||||||
dphi = math.radians(lat2 - lat1)
|
result = float(val) * SEMICIRCLES_TO_DEG
|
||||||
dlam = math.radians(lon2 - lon1)
|
if -90 <= result <= 90 or -180 <= result <= 180:
|
||||||
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
return result
|
||||||
return 2 * R * math.asin(math.sqrt(a))
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(val) -> Optional[float]:
|
def _safe_float(val) -> Optional[float]:
|
||||||
@@ -34,7 +34,17 @@ def _safe_float(val) -> Optional[float]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _bounding_box(coords: list) -> Optional[dict]:
|
def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
|
||||||
|
"""Reject the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) and fall back to dist/dur."""
|
||||||
|
fv = _safe_float(val)
|
||||||
|
if fv is None or fv >= 65.0:
|
||||||
|
if dist_m and dur_s and float(dur_s) > 0:
|
||||||
|
return float(dist_m) / float(dur_s)
|
||||||
|
return None
|
||||||
|
return fv
|
||||||
|
|
||||||
|
|
||||||
|
def _bounding_box(coords):
|
||||||
if not coords:
|
if not coords:
|
||||||
return None
|
return None
|
||||||
lats = [c[0] for c in coords]
|
lats = [c[0] for c in coords]
|
||||||
@@ -43,18 +53,35 @@ def _bounding_box(coords: list) -> Optional[dict]:
|
|||||||
"min_lon": min(lons), "max_lon": max(lons)}
|
"min_lon": min(lons), "max_lon": max(lons)}
|
||||||
|
|
||||||
|
|
||||||
def parse_fit_file(filepath: str) -> dict:
|
def _to_dt(val) -> Optional[datetime]:
|
||||||
"""Parse a Garmin .fit activity file using the official Garmin SDK."""
|
if val is None:
|
||||||
from garmin_fit_sdk import Decoder, Stream
|
return None
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
|
||||||
|
if isinstance(val, (int, float)):
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(int(val) + FIT_EPOCH_S, tz=timezone.utc)
|
||||||
|
except (OSError, OverflowError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
session = {}
|
|
||||||
|
def _is_valid_lat(v):
|
||||||
|
return v is not None and -90 <= v <= 90
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_lon(v):
|
||||||
|
return v is not None and -180 <= v <= 180
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fit_file(filepath: str) -> dict:
|
||||||
|
session_data = {}
|
||||||
records = []
|
records = []
|
||||||
laps = []
|
laps = []
|
||||||
|
|
||||||
def listener(mesg_num: int, msg: dict):
|
def listener(mesg_num: int, msg: dict):
|
||||||
nonlocal session
|
|
||||||
if mesg_num == 18: # session
|
if mesg_num == 18: # session
|
||||||
session = msg
|
session_data.update(msg)
|
||||||
elif mesg_num == 20: # record
|
elif mesg_num == 20: # record
|
||||||
records.append(msg)
|
records.append(msg)
|
||||||
elif mesg_num == 19: # lap
|
elif mesg_num == 19: # lap
|
||||||
@@ -73,68 +100,113 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
mesg_listener=listener,
|
mesg_listener=listener,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Map sport type
|
# The SDK may return field names in camelCase or snake_case depending on version.
|
||||||
sport = str(session.get("sport", "generic")).lower()
|
# Try both. Also handle raw timestamp integers for start_time.
|
||||||
sport_map = {
|
def get(d, *keys):
|
||||||
"running": "running", "cycling": "cycling", "swimming": "swimming",
|
for k in keys:
|
||||||
"hiking": "hiking", "walking": "walking", "generic": "other",
|
v = d.get(k)
|
||||||
"open_water_swimming": "swimming", "trail_running": "running",
|
if v is not None:
|
||||||
"e_biking": "cycling",
|
return v
|
||||||
}
|
return None
|
||||||
sport_type = sport_map.get(sport, sport)
|
|
||||||
|
|
||||||
start_time = session.get("start_time")
|
sport_raw = str(get(session_data, "sport", "Sport") or "generic").lower()
|
||||||
if isinstance(start_time, datetime) and start_time.tzinfo is None:
|
sport_map = {
|
||||||
start_time = start_time.replace(tzinfo=timezone.utc)
|
"running": "running", "cycling": "cycling",
|
||||||
|
"hiking": "hiking", "walking": "walking",
|
||||||
|
"generic": "other", "trail_running": "running",
|
||||||
|
"e_biking": "cycling", "open_water_swimming": "other",
|
||||||
|
}
|
||||||
|
sport_type = sport_map.get(sport_raw, sport_raw)
|
||||||
|
|
||||||
|
# start_time — SDK may return datetime or raw int
|
||||||
|
start_time_raw = get(session_data, "startTime", "start_time")
|
||||||
|
start_time = _to_dt(start_time_raw)
|
||||||
|
|
||||||
|
# Position fields — the SDK may or may not convert semicircles.
|
||||||
|
# Check if values look like semicircles (>= 90 for lat) and convert if so.
|
||||||
|
def get_lat(d):
|
||||||
|
v = get(d, "positionLat", "position_lat")
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
fv = _safe_float(v)
|
||||||
|
if fv is None:
|
||||||
|
return None
|
||||||
|
# If absolute value > 90, it's semicircles
|
||||||
|
if abs(fv) > 90:
|
||||||
|
fv = fv * SEMICIRCLES_TO_DEG
|
||||||
|
return fv if _is_valid_lat(fv) else None
|
||||||
|
|
||||||
|
def get_lon(d):
|
||||||
|
v = get(d, "positionLong", "position_long")
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
fv = _safe_float(v)
|
||||||
|
if fv is None:
|
||||||
|
return None
|
||||||
|
if abs(fv) > 180:
|
||||||
|
fv = fv * SEMICIRCLES_TO_DEG
|
||||||
|
return fv if _is_valid_lon(fv) else None
|
||||||
|
|
||||||
# Build GPS track
|
# Build GPS track
|
||||||
coords = [
|
coords = []
|
||||||
(r["position_lat"], r["position_long"])
|
for r in records:
|
||||||
for r in records
|
lat = get_lat(r)
|
||||||
if r.get("position_lat") is not None and r.get("position_long") is not None
|
lon = get_lon(r)
|
||||||
]
|
if lat is not None and lon is not None:
|
||||||
|
coords.append((lat, lon))
|
||||||
|
|
||||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||||
bounding_box = _bounding_box(coords)
|
bounding_box = _bounding_box(coords)
|
||||||
|
|
||||||
# Normalize data points
|
# Normalize data points
|
||||||
normalized_points = []
|
normalized_points = []
|
||||||
for r in records:
|
for r in records:
|
||||||
ts = r.get("timestamp")
|
ts = _to_dt(get(r, "timestamp"))
|
||||||
if isinstance(ts, datetime) and ts.tzinfo is None:
|
lat = get_lat(r)
|
||||||
ts = ts.replace(tzinfo=timezone.utc)
|
lon = get_lon(r)
|
||||||
|
|
||||||
|
altitude = get(r, "altitude", "enhancedAltitude", "enhanced_altitude")
|
||||||
|
hr = get(r, "heartRate", "heart_rate")
|
||||||
|
cadence = get(r, "cadence")
|
||||||
|
speed = get(r, "speed", "enhancedSpeed", "enhanced_speed")
|
||||||
|
power = get(r, "power")
|
||||||
|
temp = get(r, "temperature")
|
||||||
|
distance = get(r, "distance")
|
||||||
|
|
||||||
normalized_points.append({
|
normalized_points.append({
|
||||||
"timestamp": ts.isoformat() if ts else None,
|
"timestamp": ts.isoformat() if ts else None,
|
||||||
"latitude": r.get("position_lat"),
|
"latitude": _safe_float(lat),
|
||||||
"longitude": r.get("position_long"),
|
"longitude": _safe_float(lon),
|
||||||
"altitude_m": r.get("altitude") or r.get("enhanced_altitude"),
|
"altitude_m": _safe_float(altitude),
|
||||||
"heart_rate": r.get("heart_rate"),
|
"heart_rate": _safe_float(hr),
|
||||||
"cadence": r.get("cadence") or r.get("fractional_cadence"),
|
"cadence": _safe_float(cadence),
|
||||||
"speed_ms": r.get("speed") or r.get("enhanced_speed"),
|
"speed_ms": _safe_float(speed),
|
||||||
"power": r.get("power"),
|
"power": _safe_float(power),
|
||||||
"temperature_c": r.get("temperature"),
|
"temperature_c": _safe_float(temp),
|
||||||
"distance_m": r.get("distance"),
|
"distance_m": _safe_float(distance),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Normalize laps
|
# Normalize laps
|
||||||
normalized_laps = []
|
normalized_laps = []
|
||||||
for i, lap in enumerate(laps):
|
for i, lap in enumerate(laps):
|
||||||
ls = lap.get("start_time")
|
ls = _to_dt(get(lap, "startTime", "start_time"))
|
||||||
if isinstance(ls, datetime) and ls.tzinfo is None:
|
lap_dist = _safe_float(get(lap, "totalDistance", "total_distance"))
|
||||||
ls = ls.replace(tzinfo=timezone.utc)
|
lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time"))
|
||||||
normalized_laps.append({
|
normalized_laps.append({
|
||||||
"lap_number": i + 1,
|
"lap_number": i + 1,
|
||||||
"start_time": ls.isoformat() if ls else None,
|
"start_time": ls.isoformat() if ls else None,
|
||||||
"duration_s": _safe_float(lap.get("total_elapsed_time")),
|
"duration_s": lap_dur,
|
||||||
"distance_m": _safe_float(lap.get("total_distance")),
|
"distance_m": lap_dist,
|
||||||
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
|
"avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")),
|
||||||
"avg_cadence": _safe_float(lap.get("avg_cadence")),
|
"avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")),
|
||||||
"avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")),
|
"avg_speed_ms": _sanitize_speed(
|
||||||
"avg_power": _safe_float(lap.get("avg_power")),
|
get(lap, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
|
||||||
|
dist_m=lap_dist, dur_s=lap_dur,
|
||||||
|
),
|
||||||
|
"avg_power": _safe_float(get(lap, "avgPower", "avg_power")),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Build activity name
|
name = sport_type.title()
|
||||||
name = session.get("sport", "Activity").title()
|
|
||||||
if start_time:
|
if start_time:
|
||||||
name += " " + start_time.strftime("%Y-%m-%d")
|
name += " " + start_time.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
@@ -142,21 +214,28 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
"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(session.get("total_distance")),
|
"distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")),
|
||||||
"duration_s": _safe_float(session.get("total_elapsed_time")),
|
"duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
|
||||||
"elevation_gain_m": _safe_float(session.get("total_ascent")),
|
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
|
||||||
"elevation_loss_m": _safe_float(session.get("total_descent")),
|
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
|
||||||
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
|
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
|
||||||
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
|
"max_heart_rate": _safe_float(get(session_data, "maxHeartRate", "max_heart_rate")),
|
||||||
"avg_cadence": _safe_float(session.get("avg_cadence")),
|
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
|
||||||
"avg_power": _safe_float(session.get("avg_power")),
|
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
|
||||||
"normalized_power": _safe_float(session.get("normalized_power")),
|
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
|
||||||
"avg_speed_ms": _safe_float(session.get("avg_speed") or session.get("enhanced_avg_speed")),
|
"avg_speed_ms": _sanitize_speed(
|
||||||
"max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")),
|
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
|
||||||
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
|
dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")),
|
||||||
"calories": _safe_float(session.get("total_calories")),
|
dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
|
||||||
"training_stress_score": _safe_float(session.get("training_stress_score")),
|
),
|
||||||
"vo2max_estimate": _safe_float(session.get("total_training_effect")),
|
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
|
||||||
|
"enhancedMaxSpeed", "enhanced_max_speed")),
|
||||||
|
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")),
|
||||||
|
"calories": _safe_float(get(session_data, "totalCalories", "total_calories")),
|
||||||
|
"training_stress_score": _safe_float(get(session_data, "trainingStressScore",
|
||||||
|
"training_stress_score")),
|
||||||
|
"vo2max_estimate": _safe_float(get(session_data, "totalTrainingEffect",
|
||||||
|
"total_training_effect")),
|
||||||
"polyline": encoded_polyline,
|
"polyline": encoded_polyline,
|
||||||
"bounding_box": bounding_box,
|
"bounding_box": bounding_box,
|
||||||
"source_type": "fit",
|
"source_type": "fit",
|
||||||
@@ -166,7 +245,6 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def parse_gpx_file(filepath: str) -> dict:
|
def parse_gpx_file(filepath: str) -> dict:
|
||||||
"""Parse a GPX file."""
|
|
||||||
with open(filepath) as f:
|
with open(filepath) as f:
|
||||||
gpx = gpxpy.parse(f)
|
gpx = gpxpy.parse(f)
|
||||||
|
|
||||||
@@ -180,7 +258,6 @@ def parse_gpx_file(filepath: str) -> dict:
|
|||||||
ts = pt.time
|
ts = pt.time
|
||||||
if ts and ts.tzinfo is None:
|
if ts and ts.tzinfo is None:
|
||||||
ts = ts.replace(tzinfo=timezone.utc)
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
extensions = {}
|
extensions = {}
|
||||||
if pt.extensions:
|
if pt.extensions:
|
||||||
for ext in pt.extensions:
|
for ext in pt.extensions:
|
||||||
@@ -190,11 +267,9 @@ def parse_gpx_file(filepath: str) -> dict:
|
|||||||
extensions[tag] = float(child.text)
|
extensions[tag] = float(child.text)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
data_points.append({
|
data_points.append({
|
||||||
"timestamp": ts.isoformat() if ts else None,
|
"timestamp": ts.isoformat() if ts else None,
|
||||||
"latitude": pt.latitude,
|
"latitude": pt.latitude, "longitude": pt.longitude,
|
||||||
"longitude": pt.longitude,
|
|
||||||
"altitude_m": pt.elevation,
|
"altitude_m": pt.elevation,
|
||||||
"heart_rate": extensions.get("hr"),
|
"heart_rate": extensions.get("hr"),
|
||||||
"cadence": extensions.get("cad"),
|
"cadence": extensions.get("cad"),
|
||||||
@@ -204,91 +279,61 @@ def parse_gpx_file(filepath: str) -> dict:
|
|||||||
"distance_m": None,
|
"distance_m": None,
|
||||||
})
|
})
|
||||||
|
|
||||||
coords = [(p["latitude"], p["longitude"]) for p in data_points
|
coords = [(p["latitude"], p["longitude"]) for p in data_points if p["latitude"] and p["longitude"]]
|
||||||
if p["latitude"] and p["longitude"]]
|
|
||||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||||
bounding_box = _bounding_box(coords)
|
bounding_box = _bounding_box(coords)
|
||||||
|
|
||||||
# Add cumulative distance
|
|
||||||
total_dist = 0.0
|
total_dist = 0.0
|
||||||
prev = None
|
prev = None
|
||||||
for p in data_points:
|
for p in data_points:
|
||||||
if p["latitude"] and p["longitude"]:
|
if p["latitude"] and p["longitude"]:
|
||||||
if prev:
|
if prev:
|
||||||
total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"])
|
R = 6371000
|
||||||
|
phi1, phi2 = math.radians(prev[0]), math.radians(p["latitude"])
|
||||||
|
dphi = math.radians(p["latitude"] - prev[0])
|
||||||
|
dlam = math.radians(p["longitude"] - prev[1])
|
||||||
|
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
||||||
|
total_dist += 2 * R * math.asin(math.sqrt(a))
|
||||||
prev = (p["latitude"], p["longitude"])
|
prev = (p["latitude"], p["longitude"])
|
||||||
p["distance_m"] = total_dist
|
p["distance_m"] = total_dist
|
||||||
|
|
||||||
# Elevation gain/loss
|
|
||||||
uphill, downhill = 0.0, 0.0
|
uphill, downhill = 0.0, 0.0
|
||||||
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
||||||
for i in range(1, len(alts)):
|
for i in range(1, len(alts)):
|
||||||
diff = alts[i] - alts[i-1]
|
diff = alts[i] - alts[i-1]
|
||||||
if diff > 0:
|
if diff > 0: uphill += diff
|
||||||
uphill += diff
|
else: downhill += abs(diff)
|
||||||
else:
|
|
||||||
downhill += abs(diff)
|
|
||||||
|
|
||||||
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
||||||
start_time_str = data_points[0]["timestamp"] if data_points else None
|
start_time_str = data_points[0]["timestamp"] if data_points else None
|
||||||
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
|
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
|
||||||
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 = "running"
|
|
||||||
if track.type:
|
|
||||||
sport = track.type.lower()
|
|
||||||
|
|
||||||
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,
|
"sport_type": sport, "start_time": start_time_str,
|
||||||
"start_time": start_time_str,
|
"distance_m": total_dist, "duration_s": duration,
|
||||||
"distance_m": total_dist,
|
"elevation_gain_m": uphill, "elevation_loss_m": downhill,
|
||||||
"duration_s": duration,
|
|
||||||
"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_cadence": None, "avg_power": None, "normalized_power": None,
|
||||||
"avg_power": None,
|
|
||||||
"normalized_power": None,
|
|
||||||
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
|
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
|
||||||
"max_speed_ms": None,
|
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
|
||||||
"avg_temperature_c": None,
|
"training_stress_score": None, "vo2max_estimate": None,
|
||||||
"calories": None,
|
"polyline": encoded_polyline, "bounding_box": bounding_box,
|
||||||
"training_stress_score": None,
|
"source_type": "gpx", "data_points": data_points, "laps": [],
|
||||||
"vo2max_estimate": None,
|
|
||||||
"polyline": encoded_polyline,
|
|
||||||
"bounding_box": bounding_box,
|
|
||||||
"source_type": "gpx",
|
|
||||||
"data_points": data_points,
|
|
||||||
"laps": [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||||
"""
|
|
||||||
Calculate % time in each HR zone using the user's configured max HR.
|
|
||||||
|
|
||||||
Zones follow the standard 5-zone model as % of max HR:
|
|
||||||
Z1 Recovery: < 60%
|
|
||||||
Z2 Base: 60 - 70%
|
|
||||||
Z3 Tempo: 70 - 80%
|
|
||||||
Z4 Threshold: 80 - 90%
|
|
||||||
Z5 Max: > 90%
|
|
||||||
|
|
||||||
user_max_hr should be the user's actual physiological max HR, NOT the
|
|
||||||
highest HR recorded in this activity. Using activity max shifts all zones
|
|
||||||
upward and makes easy runs look harder than they are.
|
|
||||||
"""
|
|
||||||
if not user_max_hr or user_max_hr < 100:
|
if not user_max_hr or user_max_hr < 100:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
|
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
|
||||||
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
|
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
|
||||||
zones = {k: 0 for k in zone_keys}
|
zones = {k: 0 for k in zone_keys}
|
||||||
total = 0
|
total = 0
|
||||||
|
|
||||||
for p in data_points:
|
for p in data_points:
|
||||||
hr = p.get("heart_rate")
|
hr = p.get("heart_rate")
|
||||||
if not hr or hr < 20:
|
if not hr or hr < 20:
|
||||||
@@ -300,8 +345,7 @@ def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
|||||||
zones[key] += 1
|
zones[key] += 1
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
zones["z5"] += 1 # anything above 90% goes to z5
|
zones["z5"] += 1
|
||||||
|
|
||||||
if total:
|
if total:
|
||||||
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||||
return {}
|
return {}
|
||||||
@@ -524,7 +524,7 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
|||||||
|
|
||||||
if stats:
|
if stats:
|
||||||
_set(row, "resting_hr", stats.get("restingHeartRate"))
|
_set(row, "resting_hr", stats.get("restingHeartRate"))
|
||||||
_set(row, "avg_hr_day", stats.get("averageHeartRate"))
|
# averageHeartRate is absent from get_stats; avg_hr_day is computed below from intraday HR
|
||||||
_set(row, "max_hr_day", stats.get("maxHeartRate"))
|
_set(row, "max_hr_day", stats.get("maxHeartRate"))
|
||||||
_set(row, "steps", stats.get("totalSteps"))
|
_set(row, "steps", stats.get("totalSteps"))
|
||||||
_set(row, "floors_climbed", stats.get("floorsAscended"))
|
_set(row, "floors_climbed", stats.get("floorsAscended"))
|
||||||
|
|||||||
@@ -63,11 +63,21 @@ def routes_are_similar(
|
|||||||
bb1: Optional[dict],
|
bb1: Optional[dict],
|
||||||
bb2: Optional[dict],
|
bb2: Optional[dict],
|
||||||
dtw_threshold_m: float = 80.0,
|
dtw_threshold_m: float = 80.0,
|
||||||
|
dist1: Optional[float] = None,
|
||||||
|
dist2: Optional[float] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Returns True if two activities are on sufficiently similar routes.
|
Returns True if two activities are on sufficiently similar routes.
|
||||||
First does a cheap bounding box check, then DTW on downsampled tracks.
|
First does a cheap bounding box check, then DTW on downsampled tracks.
|
||||||
|
When dist1/dist2 are provided:
|
||||||
|
- Rejects if distance differs by more than 2.5%
|
||||||
|
- Uses 3% of route distance as the DTW threshold (capped at 300m)
|
||||||
"""
|
"""
|
||||||
|
if dist1 and dist2 and dist1 > 0 and dist2 > 0:
|
||||||
|
if abs(dist1 - dist2) / max(dist1, dist2) > 0.025:
|
||||||
|
return False
|
||||||
|
dtw_threshold_m = min(max(dist1, dist2) * 0.03, 300.0)
|
||||||
|
|
||||||
if bb1 and bb2:
|
if bb1 and bb2:
|
||||||
if not bounding_boxes_overlap(bb1, bb2):
|
if not bounding_boxes_overlap(bb1, bb2):
|
||||||
return False
|
return False
|
||||||
@@ -164,6 +174,154 @@ 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"),
|
||||||
|
|||||||
@@ -1,56 +1,61 @@
|
|||||||
"""
|
"""
|
||||||
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
|
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
|
||||||
|
The SDK with convert_types_to_strings=True returns snake_case field names.
|
||||||
|
|
||||||
The official SDK (garmin-fit-sdk) correctly handles:
|
Sleep stages: message 275 (modern) or 269 (older) each carry a start timestamp
|
||||||
- Standard FIT messages (monitoring, hrv_status_summary, sleep_level etc.)
|
and a stage name. Duration of each stage = gap to the next stage's timestamp.
|
||||||
- Garmin proprietary messages stored by numeric mesg_num
|
The sleep session stop time (from event message 21, event_type='stop') closes
|
||||||
- Unknown fields stored by field definition number
|
the last stage.
|
||||||
- Scale/offset application, component expansion, HR merging
|
|
||||||
|
|
||||||
Fenix 6X proprietary message numbers identified by binary analysis:
|
|
||||||
55 - activity accumulation snapshots (cumulative steps, HR per interval)
|
|
||||||
103 - daily totals summary (total steps, floors, calories)
|
|
||||||
211 - resting HR + HRV summary
|
|
||||||
227 - per-minute stress level + heart rate (most valuable for health dashboard)
|
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone, timedelta, date
|
from datetime import datetime, timezone, date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from garmin_fit_sdk import Decoder, Stream
|
||||||
|
|
||||||
|
|
||||||
FIT_EPOCH_S = 631065600 # seconds between Unix epoch and FIT epoch (Dec 31 1989)
|
FIT_EPOCH_S = 631065600
|
||||||
|
SLEEP_LEVEL_MAP = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
|
||||||
|
|
||||||
|
|
||||||
def fit_ts(seconds) -> Optional[datetime]:
|
def _fit_ts(raw) -> Optional[datetime]:
|
||||||
"""Convert FIT timestamp to UTC datetime."""
|
if raw is None:
|
||||||
if seconds is None:
|
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
s = int(seconds)
|
s = int(raw)
|
||||||
if s == 0 or s == 0xFFFFFFFF:
|
if s <= 0 or s == 0xFFFFFFFF:
|
||||||
return None
|
return None
|
||||||
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
|
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
|
||||||
except (TypeError, ValueError, OverflowError, OSError):
|
except (TypeError, ValueError, OverflowError, OSError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _is_datetime(v) -> bool:
|
def _to_date(val) -> Optional[date]:
|
||||||
return isinstance(v, datetime)
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
if val.tzinfo is None:
|
||||||
|
val = val.replace(tzinfo=timezone.utc)
|
||||||
|
return val.date()
|
||||||
|
if isinstance(val, (int, float)):
|
||||||
|
dt = _fit_ts(val)
|
||||||
|
return dt.date() if dt else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dt(val) -> Optional[datetime]:
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
|
||||||
|
if isinstance(val, (int, float)):
|
||||||
|
return _fit_ts(val)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_wellness_fit(file_path: str) -> dict:
|
def parse_wellness_fit(file_path: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Parse a Garmin wellness/monitoring FIT file using the official Garmin SDK.
|
Parse a Garmin wellness/monitoring FIT file.
|
||||||
|
|
||||||
Returns {"days": {date: metrics_dict}, "error": str|None}
|
Returns {"days": {date: metrics_dict}, "error": str|None}
|
||||||
"""
|
"""
|
||||||
try:
|
daily = {}
|
||||||
from garmin_fit_sdk import Decoder, Stream
|
last_date_seen = [None]
|
||||||
except ImportError:
|
|
||||||
# Fall back to fitparse-based parser if SDK not installed yet
|
|
||||||
from app.services.wellness_parser_fallback import parse_wellness_fit as _fb
|
|
||||||
return _fb(file_path)
|
|
||||||
|
|
||||||
daily = {} # date -> aggregation dict
|
|
||||||
|
|
||||||
def ensure_day(d: date) -> dict:
|
def ensure_day(d: date) -> dict:
|
||||||
if d not in daily:
|
if d not in daily:
|
||||||
@@ -58,195 +63,213 @@ def parse_wellness_fit(file_path: str) -> dict:
|
|||||||
"heart_rates": [],
|
"heart_rates": [],
|
||||||
"stress_values": [],
|
"stress_values": [],
|
||||||
"spo2_readings": [],
|
"spo2_readings": [],
|
||||||
"sleep_levels": [],
|
# Each entry: (datetime, level_int) — duration computed from gaps
|
||||||
|
"sleep_epochs": [],
|
||||||
|
"sleep_start": None,
|
||||||
|
"sleep_end": None,
|
||||||
"steps": None,
|
"steps": None,
|
||||||
"floors_climbed": None,
|
"floors_climbed": None,
|
||||||
"active_calories": None,
|
"active_calories": None,
|
||||||
"total_calories": None,
|
"bmr": None,
|
||||||
"resting_hr": None,
|
"resting_hr": None,
|
||||||
"hrv_nightly_avg": None,
|
"hrv_nightly_avg": None,
|
||||||
"hrv_5min_high": None,
|
"hrv_5min_high": None,
|
||||||
"hrv_status": None,
|
"hrv_status": None,
|
||||||
|
"sleep_score": None,
|
||||||
}
|
}
|
||||||
return daily[d]
|
return daily[d]
|
||||||
|
|
||||||
def get_date(msg: dict, *keys) -> Optional[date]:
|
def _add_sleep_epoch(ts: datetime, level_raw):
|
||||||
"""Extract a date from a message, trying multiple field names."""
|
d = _to_date(ts)
|
||||||
for key in keys:
|
if not d:
|
||||||
v = msg.get(key)
|
return
|
||||||
if v is None:
|
last_date_seen[0] = d
|
||||||
continue
|
if isinstance(level_raw, str):
|
||||||
if _is_datetime(v):
|
level = SLEEP_LEVEL_MAP.get(level_raw.lower())
|
||||||
return v.date()
|
else:
|
||||||
if isinstance(v, (int, float)):
|
level = level_raw
|
||||||
dt = fit_ts(v)
|
if level is not None:
|
||||||
if dt:
|
ensure_day(d)["sleep_epochs"].append((ts, int(level)))
|
||||||
return dt.date()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def listener(mesg_num: int, msg: dict):
|
def listener(mesg_num: int, msg: dict):
|
||||||
"""Called for every message after full decoding."""
|
|
||||||
|
|
||||||
# ── Standard: monitoring (148) ────────────────────────────────────
|
# ── monitoring_info (147) - older firmware ─────────────────────────
|
||||||
if mesg_num == 148:
|
if mesg_num == 147:
|
||||||
d = get_date(msg, "timestamp", "local_timestamp")
|
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
|
||||||
|
rhr = msg.get("resting_heart_rate")
|
||||||
|
if d and rhr and 20 < rhr < 120:
|
||||||
|
last_date_seen[0] = d
|
||||||
|
ensure_day(d)["resting_hr"] = int(rhr)
|
||||||
|
|
||||||
|
# ── monitoring (148) - older firmware ──────────────────────────────
|
||||||
|
elif mesg_num == 148:
|
||||||
|
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
|
||||||
if not d:
|
if not d:
|
||||||
return
|
return
|
||||||
|
last_date_seen[0] = d
|
||||||
entry = ensure_day(d)
|
entry = ensure_day(d)
|
||||||
|
|
||||||
hr = msg.get("heart_rate")
|
hr = msg.get("heart_rate")
|
||||||
if hr and 20 < hr < 250:
|
if hr and 20 < hr < 250:
|
||||||
entry["heart_rates"].append(int(hr))
|
entry["heart_rates"].append(int(hr))
|
||||||
|
|
||||||
steps = msg.get("steps") or msg.get("cycles")
|
steps = msg.get("steps") or msg.get("cycles")
|
||||||
if steps and steps > 0:
|
if steps and steps > 0:
|
||||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||||
|
|
||||||
stress = msg.get("stress_level_value")
|
stress = msg.get("stress_level_value")
|
||||||
if stress is not None and stress >= 0:
|
if stress is not None and stress >= 0:
|
||||||
entry["stress_values"].append(int(stress))
|
entry["stress_values"].append(int(stress))
|
||||||
|
|
||||||
# ── Standard: monitoring_info (147) ───────────────────────────────
|
# ── monitoring (55) - modern, per-interval running totals ──────────
|
||||||
elif mesg_num == 147:
|
elif mesg_num == 55:
|
||||||
d = get_date(msg, "timestamp", "local_timestamp")
|
d = _to_date(msg.get("timestamp"))
|
||||||
if not d:
|
|
||||||
return
|
|
||||||
rhr = msg.get("resting_heart_rate")
|
|
||||||
if rhr and 20 < rhr < 120:
|
|
||||||
ensure_day(d)["resting_hr"] = int(rhr)
|
|
||||||
|
|
||||||
# ── Standard: hrv_status_summary (275) ────────────────────────────
|
|
||||||
elif mesg_num == 275:
|
|
||||||
d = get_date(msg, "timestamp")
|
|
||||||
if not d:
|
if not d:
|
||||||
return
|
return
|
||||||
|
last_date_seen[0] = d
|
||||||
entry = ensure_day(d)
|
entry = ensure_day(d)
|
||||||
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
|
hr = msg.get("heart_rate")
|
||||||
v = msg.get(key)
|
if hr and 20 < hr < 250:
|
||||||
if v:
|
entry["heart_rates"].append(int(hr))
|
||||||
entry["hrv_nightly_avg"] = float(v)
|
steps = msg.get("steps")
|
||||||
break
|
if steps and steps > 0:
|
||||||
high = msg.get("last_night_5_min_high")
|
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||||
if high:
|
active_cal = msg.get("active_calories")
|
||||||
entry["hrv_5min_high"] = float(high)
|
if active_cal and active_cal > 0:
|
||||||
status = msg.get("hrv_status")
|
entry["active_calories"] = max(entry["active_calories"] or 0, float(active_cal))
|
||||||
|
ascent = msg.get("ascent")
|
||||||
|
if ascent and ascent > 0:
|
||||||
|
# Garmin counts 1 floor ≈ 3 m of ascent
|
||||||
|
floors = max(1, round(float(ascent) / 3))
|
||||||
|
entry["floors_climbed"] = max(entry["floors_climbed"] or 0, floors)
|
||||||
|
|
||||||
|
# ── monitoring_info (103) - calibration; carries BMR ───────────────
|
||||||
|
elif mesg_num == 103:
|
||||||
|
d = _to_date(msg.get("timestamp"))
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
last_date_seen[0] = d
|
||||||
|
bmr = msg.get("resting_metabolic_rate")
|
||||||
|
if bmr and bmr > 0:
|
||||||
|
ensure_day(d)["bmr"] = int(bmr)
|
||||||
|
|
||||||
|
# ── hrv_status_summary (370) - modern HRV ─────────────────────────
|
||||||
|
elif mesg_num == 370:
|
||||||
|
d = _to_date(msg.get("timestamp"))
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
last_date_seen[0] = d
|
||||||
|
entry = ensure_day(d)
|
||||||
|
hrv_avg = msg.get("last_night_average")
|
||||||
|
if hrv_avg and hrv_avg > 0:
|
||||||
|
entry["hrv_nightly_avg"] = float(hrv_avg)
|
||||||
|
hrv_high = msg.get("last_night_5_min_high")
|
||||||
|
if hrv_high and hrv_high > 0:
|
||||||
|
entry["hrv_5min_high"] = float(hrv_high)
|
||||||
|
status = msg.get("status")
|
||||||
if status:
|
if status:
|
||||||
entry["hrv_status"] = str(status)
|
entry["hrv_status"] = str(status)
|
||||||
|
|
||||||
# ── Standard: stress_level (132) ──────────────────────────────────
|
# ── message 275 - sleep epochs (modern) or HRV (older firmware) ───
|
||||||
elif mesg_num == 132:
|
elif mesg_num == 275:
|
||||||
d = get_date(msg, "stress_level_time", "timestamp")
|
sleep_level = msg.get("sleep_level")
|
||||||
|
ts = _to_dt(msg.get("timestamp"))
|
||||||
|
if sleep_level is not None and ts:
|
||||||
|
_add_sleep_epoch(ts, sleep_level)
|
||||||
|
elif ts:
|
||||||
|
# Older firmware: HRV summary in message 275
|
||||||
|
d = _to_date(ts)
|
||||||
|
if d:
|
||||||
|
last_date_seen[0] = d
|
||||||
|
entry = ensure_day(d)
|
||||||
|
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
|
||||||
|
v = msg.get(key)
|
||||||
|
if v and v > 0:
|
||||||
|
entry["hrv_nightly_avg"] = float(v)
|
||||||
|
break
|
||||||
|
high = msg.get("last_night_5_min_high")
|
||||||
|
if high:
|
||||||
|
entry["hrv_5min_high"] = float(high)
|
||||||
|
status = msg.get("hrv_status") or msg.get("status")
|
||||||
|
if status:
|
||||||
|
entry["hrv_status"] = str(status)
|
||||||
|
|
||||||
|
# ── sleep_level (269) - older firmware sleep epochs ────────────────
|
||||||
|
elif mesg_num == 269:
|
||||||
|
ts = _to_dt(msg.get("timestamp"))
|
||||||
|
level = msg.get("sleep_level")
|
||||||
|
if ts and level is not None:
|
||||||
|
_add_sleep_epoch(ts, level)
|
||||||
|
|
||||||
|
# ── event (21) - sleep session start / stop ────────────────────────
|
||||||
|
elif mesg_num == 21:
|
||||||
|
ts = _to_dt(msg.get("timestamp"))
|
||||||
|
if not ts:
|
||||||
|
return
|
||||||
|
d = _to_date(ts)
|
||||||
if not d:
|
if not d:
|
||||||
return
|
return
|
||||||
|
event_type = msg.get("event_type")
|
||||||
|
if event_type == "start":
|
||||||
|
last_date_seen[0] = d
|
||||||
|
ensure_day(d)["sleep_start"] = ts
|
||||||
|
elif event_type == "stop":
|
||||||
|
last_date_seen[0] = d
|
||||||
|
ensure_day(d)["sleep_end"] = ts
|
||||||
|
|
||||||
|
# ── sleep_assessment (346) - overall sleep score, no timestamp ────
|
||||||
|
elif mesg_num == 346:
|
||||||
|
d = last_date_seen[0]
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
score = msg.get("overall_sleep_score")
|
||||||
|
if score and score > 0:
|
||||||
|
ensure_day(d)["sleep_score"] = int(score)
|
||||||
|
|
||||||
|
# ── stress_level (132) ─────────────────────────────────────────────
|
||||||
|
elif mesg_num == 132:
|
||||||
|
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
last_date_seen[0] = d
|
||||||
stress = msg.get("stress_level_value")
|
stress = msg.get("stress_level_value")
|
||||||
if stress is not None and stress >= 0:
|
if stress is not None and stress >= 0:
|
||||||
ensure_day(d)["stress_values"].append(int(stress))
|
ensure_day(d)["stress_values"].append(int(stress))
|
||||||
|
|
||||||
# ── Standard: spo2_data (258) ─────────────────────────────────────
|
# ── spo2_data (258) ────────────────────────────────────────────────
|
||||||
elif mesg_num == 258:
|
elif mesg_num == 258:
|
||||||
d = get_date(msg, "timestamp")
|
d = _to_date(msg.get("timestamp"))
|
||||||
if not d:
|
if not d:
|
||||||
return
|
return
|
||||||
|
last_date_seen[0] = d
|
||||||
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
|
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
|
||||||
if spo2 and 50 < spo2 <= 100:
|
if spo2 and 50 < spo2 <= 100:
|
||||||
ensure_day(d)["spo2_readings"].append(float(spo2))
|
ensure_day(d)["spo2_readings"].append(float(spo2))
|
||||||
|
|
||||||
# ── Standard: sleep_level (269) ───────────────────────────────────
|
# ── per-minute stress + HR (227) proprietary ───────────────────────
|
||||||
elif mesg_num == 269:
|
elif mesg_num == 227:
|
||||||
d = get_date(msg, "timestamp")
|
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
|
||||||
if not d:
|
if not d:
|
||||||
return
|
return
|
||||||
level = msg.get("sleep_level")
|
last_date_seen[0] = d
|
||||||
if level is not None:
|
entry = ensure_day(d)
|
||||||
# Convert string level names to numeric codes if SDK decoded them
|
hr_raw = msg.get(2)
|
||||||
if isinstance(level, str):
|
|
||||||
level_map = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
|
|
||||||
level = level_map.get(level.lower())
|
|
||||||
if level is not None:
|
|
||||||
ensure_day(d)["sleep_levels"].append(int(level))
|
|
||||||
|
|
||||||
# ── Proprietary 227: per-minute stress + HR ───────────────────────
|
|
||||||
# field_1 = FIT timestamp, field_2 = heart rate bpm, field_0 = stress
|
|
||||||
elif mesg_num == 227:
|
|
||||||
# SDK stores unknown fields as "unknown_N" or by def_num
|
|
||||||
ts_raw = msg.get(1) or msg.get("unknown_1") or msg.get("field_1")
|
|
||||||
hr_raw = msg.get(2) or msg.get("unknown_2") or msg.get("field_2")
|
|
||||||
stress_raw = msg.get(0) or msg.get("unknown_0") or msg.get("field_0")
|
|
||||||
|
|
||||||
ts = fit_ts(ts_raw) if isinstance(ts_raw, (int, float)) else (
|
|
||||||
ts_raw if _is_datetime(ts_raw) else None
|
|
||||||
)
|
|
||||||
if not ts:
|
|
||||||
return
|
|
||||||
entry = ensure_day(ts.date())
|
|
||||||
|
|
||||||
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
|
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
|
||||||
entry["heart_rates"].append(int(hr_raw))
|
entry["heart_rates"].append(int(hr_raw))
|
||||||
|
stress = msg.get("stress_level_value")
|
||||||
|
if stress is None:
|
||||||
|
stress = msg.get(0)
|
||||||
|
if stress is not None and isinstance(stress, (int, float)) and stress >= 0:
|
||||||
|
entry["stress_values"].append(int(stress))
|
||||||
|
|
||||||
if stress_raw is not None and isinstance(stress_raw, (int, float)) and stress_raw >= 0:
|
# ── daily resting HR (211) proprietary ─────────────────────────────
|
||||||
entry["stress_values"].append(int(stress_raw))
|
|
||||||
|
|
||||||
# ── Proprietary 103: daily totals summary ─────────────────────────
|
|
||||||
# field_253 = timestamp, field_3 = steps, field_4 = floors, field_5/7 = cal
|
|
||||||
elif mesg_num == 103:
|
|
||||||
ts_v = msg.get(253) or msg.get("timestamp")
|
|
||||||
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
|
||||||
if not ts:
|
|
||||||
return
|
|
||||||
entry = ensure_day(ts.date())
|
|
||||||
|
|
||||||
steps = msg.get(3)
|
|
||||||
if steps and isinstance(steps, (int, float)) and steps > 0:
|
|
||||||
entry["steps"] = int(steps)
|
|
||||||
|
|
||||||
floors = msg.get(4)
|
|
||||||
if floors and isinstance(floors, (int, float)) and floors > 0:
|
|
||||||
f = float(floors)
|
|
||||||
if f > 1000:
|
|
||||||
f = f / 100
|
|
||||||
entry["floors_climbed"] = round(f, 1)
|
|
||||||
|
|
||||||
active_cal = msg.get(5)
|
|
||||||
if active_cal and isinstance(active_cal, (int, float)) and active_cal > 0:
|
|
||||||
entry["active_calories"] = float(active_cal)
|
|
||||||
|
|
||||||
total_cal = msg.get(7)
|
|
||||||
if total_cal and isinstance(total_cal, (int, float)) and total_cal > 0:
|
|
||||||
entry["total_calories"] = float(total_cal)
|
|
||||||
|
|
||||||
# ── Proprietary 211: resting HR + HRV summary ─────────────────────
|
|
||||||
elif mesg_num == 211:
|
elif mesg_num == 211:
|
||||||
ts_v = msg.get(253) or msg.get("timestamp")
|
d = _to_date(msg.get("timestamp"))
|
||||||
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
if not d:
|
||||||
if not ts:
|
|
||||||
return
|
return
|
||||||
entry = ensure_day(ts.date())
|
last_date_seen[0] = d
|
||||||
|
entry = ensure_day(d)
|
||||||
rhr = msg.get(0)
|
rhr = msg.get("resting_heart_rate") or msg.get("current_day_resting_heart_rate")
|
||||||
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
|
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
|
||||||
entry["resting_hr"] = int(rhr)
|
entry["resting_hr"] = int(rhr)
|
||||||
|
|
||||||
hrv = msg.get(1)
|
|
||||||
if hrv and isinstance(hrv, (int, float)) and 5 < hrv < 300:
|
|
||||||
entry["hrv_nightly_avg"] = float(hrv)
|
|
||||||
|
|
||||||
# ── Proprietary 55: activity accumulation snapshots ───────────────
|
|
||||||
elif mesg_num == 55:
|
|
||||||
ts_v = msg.get(253) or msg.get("timestamp")
|
|
||||||
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
|
||||||
if not ts:
|
|
||||||
return
|
|
||||||
entry = ensure_day(ts.date())
|
|
||||||
|
|
||||||
steps = msg.get(2)
|
|
||||||
if steps and isinstance(steps, (int, float)) and steps > 0:
|
|
||||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
|
||||||
|
|
||||||
hr = msg.get(19)
|
|
||||||
if hr and isinstance(hr, (int, float)) and 20 < hr < 250:
|
|
||||||
entry["heart_rates"].append(int(hr))
|
|
||||||
|
|
||||||
# Decode the file
|
|
||||||
try:
|
try:
|
||||||
stream = Stream.from_file(file_path)
|
stream = Stream.from_file(file_path)
|
||||||
decoder = Decoder(stream)
|
decoder = Decoder(stream)
|
||||||
@@ -254,37 +277,57 @@ def parse_wellness_fit(file_path: str) -> dict:
|
|||||||
apply_scale_and_offset=True,
|
apply_scale_and_offset=True,
|
||||||
convert_datetimes_to_dates=True,
|
convert_datetimes_to_dates=True,
|
||||||
convert_types_to_strings=True,
|
convert_types_to_strings=True,
|
||||||
enable_crc_check=False, # wellness files sometimes have bad CRCs
|
enable_crc_check=False,
|
||||||
expand_sub_fields=True,
|
expand_sub_fields=True,
|
||||||
expand_components=True,
|
expand_components=True,
|
||||||
merge_heart_rates=True,
|
merge_heart_rates=False,
|
||||||
mesg_listener=listener,
|
mesg_listener=listener,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e), "days": {}}
|
return {"error": str(e), "days": {}}
|
||||||
|
|
||||||
# Aggregate per-day
|
|
||||||
result = {}
|
result = {}
|
||||||
for day_date, data in daily.items():
|
for day_date, data in daily.items():
|
||||||
hrs = data.pop("heart_rates", [])
|
hrs = data.pop("heart_rates", [])
|
||||||
stresses = data.pop("stress_values", [])
|
stresses = data.pop("stress_values", [])
|
||||||
spo2s = data.pop("spo2_readings", [])
|
spo2s = data.pop("spo2_readings", [])
|
||||||
sleep_levels = data.pop("sleep_levels", [])
|
sleep_epochs = data.pop("sleep_epochs", [])
|
||||||
|
sleep_end_ts = data.pop("sleep_end", None)
|
||||||
|
sleep_start_ts = data.pop("sleep_start", None)
|
||||||
|
|
||||||
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
|
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
|
||||||
max_hr = max(hrs) if hrs else None
|
max_hr = max(hrs) if hrs else None
|
||||||
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
|
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
|
||||||
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
|
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
|
||||||
|
|
||||||
# Sleep stage seconds (each level record = 30s epoch)
|
# Compute sleep stage durations from epoch timestamps
|
||||||
if sleep_levels:
|
if sleep_epochs:
|
||||||
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) or None
|
epochs_sorted = sorted(sleep_epochs, key=lambda x: x[0])
|
||||||
sleep_light_s = sum(30 for l in sleep_levels if l == 2) or None
|
level_secs = {1: 0, 2: 0, 3: 0, 4: 0} # awake, light, deep, rem
|
||||||
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) or None
|
for i, (ts, level) in enumerate(epochs_sorted):
|
||||||
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) or None
|
if i + 1 < len(epochs_sorted):
|
||||||
sleep_duration_s = (sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0) or None
|
next_ts = epochs_sorted[i + 1][0]
|
||||||
|
elif sleep_end_ts:
|
||||||
|
next_ts = sleep_end_ts
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
dur = (next_ts - ts).total_seconds()
|
||||||
|
if level in level_secs and dur > 0:
|
||||||
|
level_secs[level] += dur
|
||||||
|
sleep_deep_s = level_secs[3] or None
|
||||||
|
sleep_light_s = level_secs[2] or None
|
||||||
|
sleep_rem_s = level_secs[4] or None
|
||||||
|
sleep_awake_s = level_secs[1] or None
|
||||||
|
sleep_duration_s = (level_secs[2] + level_secs[3] + level_secs[4]) or None
|
||||||
|
sleep_stages = [[int(ts.timestamp() * 1000), level] for ts, level in epochs_sorted]
|
||||||
else:
|
else:
|
||||||
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
|
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
|
||||||
|
sleep_stages = None
|
||||||
|
|
||||||
|
active_cal = data.get("active_calories")
|
||||||
|
bmr = data.get("bmr")
|
||||||
|
# Require active_cal so we don't store BMR-only as "total" calories
|
||||||
|
total_cal = float(bmr + active_cal) if (bmr and active_cal) else None
|
||||||
|
|
||||||
result[day_date] = {
|
result[day_date] = {
|
||||||
"resting_hr": data.get("resting_hr"),
|
"resting_hr": data.get("resting_hr"),
|
||||||
@@ -297,13 +340,17 @@ def parse_wellness_fit(file_path: str) -> dict:
|
|||||||
"hrv_status": data.get("hrv_status"),
|
"hrv_status": data.get("hrv_status"),
|
||||||
"steps": data.get("steps"),
|
"steps": data.get("steps"),
|
||||||
"floors_climbed": data.get("floors_climbed"),
|
"floors_climbed": data.get("floors_climbed"),
|
||||||
"active_calories": data.get("active_calories"),
|
"active_calories": active_cal,
|
||||||
"total_calories": data.get("total_calories"),
|
"total_calories": total_cal,
|
||||||
"sleep_duration_s": sleep_duration_s,
|
"sleep_duration_s": sleep_duration_s,
|
||||||
"sleep_deep_s": sleep_deep_s,
|
"sleep_deep_s": sleep_deep_s,
|
||||||
"sleep_light_s": sleep_light_s,
|
"sleep_light_s": sleep_light_s,
|
||||||
"sleep_rem_s": sleep_rem_s,
|
"sleep_rem_s": sleep_rem_s,
|
||||||
"sleep_awake_s": sleep_awake_s,
|
"sleep_awake_s": sleep_awake_s,
|
||||||
|
"sleep_score": data.get("sleep_score"),
|
||||||
|
"sleep_start": sleep_start_ts,
|
||||||
|
"sleep_end": sleep_end_ts,
|
||||||
|
"sleep_stages": sleep_stages,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"days": result, "error": None}
|
return {"days": result, "error": None}
|
||||||
|
|||||||
@@ -22,18 +22,27 @@ celery_app.conf.update(
|
|||||||
enable_utc=True,
|
enable_utc=True,
|
||||||
task_track_started=True,
|
task_track_started=True,
|
||||||
worker_prefetch_multiplier=1,
|
worker_prefetch_multiplier=1,
|
||||||
|
beat_schedule={
|
||||||
|
"sync-garmin-connect": {
|
||||||
|
"task": "sync_all_garmin_connect",
|
||||||
|
"schedule": 1800.0, # every 30 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Garmin FIT file suffixes that are health/wellness data, not activities
|
|
||||||
WELLNESS_SUFFIXES = (
|
WELLNESS_SUFFIXES = (
|
||||||
"_METRICS.fit",
|
"_METRICS.fit",
|
||||||
"_WELLNESS.fit",
|
"_WELLNESS.fit",
|
||||||
"_SLEEP.fit",
|
"_SLEEP.fit",
|
||||||
|
"_SLEEP_DATA.fit",
|
||||||
"_STRESS.fit",
|
"_STRESS.fit",
|
||||||
"_SPO2.fit",
|
"_SPO2.fit",
|
||||||
"_HRV.fit",
|
"_HRV.fit",
|
||||||
|
"_HRV_STATUS.fit",
|
||||||
"_MONITORING.fit",
|
"_MONITORING.fit",
|
||||||
"_MONITORING_B.fit",
|
"_MONITORING_B.fit",
|
||||||
|
"_RESPIRATION.fit",
|
||||||
|
"_PULSE_OX.fit",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -43,10 +52,10 @@ def is_wellness_file(file_path: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="process_activity_file")
|
@celery_app.task(bind=True, name="process_activity_file")
|
||||||
def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||||
|
garmin_activity_id: str = None):
|
||||||
"""Parse a FIT/GPX file. Routes wellness files to health parser."""
|
"""Parse a FIT/GPX file. Routes wellness files to health parser."""
|
||||||
|
|
||||||
# Route wellness/metrics files to health parser instead
|
|
||||||
if is_wellness_file(file_path):
|
if is_wellness_file(file_path):
|
||||||
parse_wellness_fit.delay(file_path, user_id)
|
parse_wellness_fit.delay(file_path, user_id)
|
||||||
return {"status": "routed_to_wellness", "file": file_path}
|
return {"status": "routed_to_wellness", "file": file_path}
|
||||||
@@ -54,7 +63,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
|
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
|
||||||
from app.core.database import SyncSessionLocal
|
from app.core.database import SyncSessionLocal
|
||||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
self.update_state(state="PROGRESS", meta={"step": "parsing"})
|
self.update_state(state="PROGRESS", meta={"step": "parsing"})
|
||||||
@@ -67,25 +76,37 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise self.retry(exc=e, countdown=10, max_retries=3)
|
raise self.retry(exc=e, countdown=10, max_retries=3)
|
||||||
|
|
||||||
# Skip files with no usable activity data
|
|
||||||
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}
|
||||||
|
|
||||||
with SyncSessionLocal() as db:
|
with SyncSessionLocal() as db:
|
||||||
# Check for duplicate by garmin activity ID
|
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||||
if parsed.get("garmin_activity_id"):
|
|
||||||
existing = db.execute(
|
|
||||||
select(Activity).where(
|
|
||||||
Activity.garmin_activity_id == parsed["garmin_activity_id"]
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if existing:
|
|
||||||
return {"activity_id": existing.id, "status": "duplicate"}
|
|
||||||
|
|
||||||
# Get user's configured max HR for accurate zone calculation
|
# Deduplicate: same user + sport_type + start_time within ±60s
|
||||||
# Falls back to: user-set value → 220-age → activity max → 190
|
from datetime import timedelta
|
||||||
|
existing = db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.user_id == user_id,
|
||||||
|
Activity.sport_type == parsed["sport_type"],
|
||||||
|
Activity.start_time >= start_time - timedelta(seconds=60),
|
||||||
|
Activity.start_time <= start_time + timedelta(seconds=60),
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Stamp garmin_activity_id if this came from a Garmin Connect sync
|
||||||
|
# so future syncs skip the fast-path dedup and don't re-download.
|
||||||
|
if garmin_activity_id and not existing.garmin_activity_id:
|
||||||
|
existing.garmin_activity_id = garmin_activity_id
|
||||||
|
db.commit()
|
||||||
|
return {"activity_id": existing.id, "status": "duplicate"}
|
||||||
|
|
||||||
|
# Get user max HR for zone calculation
|
||||||
from app.models.user import User as UserModel
|
from app.models.user import User as UserModel
|
||||||
user_obj = db.execute(select(UserModel).where(UserModel.id == user_id)).scalar_one_or_none()
|
user_obj = db.execute(
|
||||||
|
select(UserModel).where(UserModel.id == user_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
user_max_hr = None
|
user_max_hr = None
|
||||||
if user_obj:
|
if user_obj:
|
||||||
user_max_hr = user_obj.max_heart_rate
|
user_max_hr = user_obj.max_heart_rate
|
||||||
@@ -94,20 +115,15 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
age = _date.today().year - user_obj.birth_year
|
age = _date.today().year - user_obj.birth_year
|
||||||
user_max_hr = 220 - age
|
user_max_hr = 220 - age
|
||||||
if not user_max_hr:
|
if not user_max_hr:
|
||||||
# Last resort: use activity max but warn this may shift zones
|
|
||||||
user_max_hr = parsed.get("max_heart_rate") or 190
|
user_max_hr = parsed.get("max_heart_rate") or 190
|
||||||
|
|
||||||
hr_zones = calculate_hr_zones(
|
hr_zones = calculate_hr_zones(parsed.get("data_points", []), user_max_hr)
|
||||||
parsed.get("data_points", []),
|
|
||||||
user_max_hr
|
|
||||||
)
|
|
||||||
|
|
||||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
|
||||||
|
|
||||||
activity = Activity(
|
activity = Activity(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=parsed["name"],
|
name=parsed["name"],
|
||||||
sport_type=parsed["sport_type"],
|
sport_type=parsed["sport_type"],
|
||||||
|
garmin_activity_id=garmin_activity_id,
|
||||||
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"),
|
||||||
@@ -132,11 +148,9 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
db.add(activity)
|
db.add(activity)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Insert data points, deduping on (activity_id, timestamp)
|
|
||||||
seen = set()
|
seen = set()
|
||||||
points = parsed.get("data_points", [])
|
|
||||||
batch = []
|
batch = []
|
||||||
for p in points:
|
for p in parsed.get("data_points", []):
|
||||||
if not p.get("timestamp"):
|
if not p.get("timestamp"):
|
||||||
continue
|
continue
|
||||||
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
|
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
|
||||||
@@ -165,7 +179,6 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
db.add_all(batch)
|
db.add_all(batch)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Laps
|
|
||||||
for lap in parsed.get("laps", []):
|
for lap in parsed.get("laps", []):
|
||||||
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
|
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
|
||||||
db.add(ActivityLap(
|
db.add(ActivityLap(
|
||||||
@@ -184,7 +197,6 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
activity_id = activity.id
|
activity_id = activity.id
|
||||||
|
|
||||||
compute_personal_records.delay(activity_id, user_id, parsed)
|
compute_personal_records.delay(activity_id, user_id, parsed)
|
||||||
# Auto route detection for running and cycling
|
|
||||||
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)
|
||||||
return {"activity_id": activity_id, "status": "ok"}
|
return {"activity_id": activity_id, "status": "ok"}
|
||||||
@@ -192,16 +204,17 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
|
|
||||||
@celery_app.task(name="parse_wellness_fit")
|
@celery_app.task(name="parse_wellness_fit")
|
||||||
def parse_wellness_fit(file_path: str, user_id: int):
|
def parse_wellness_fit(file_path: str, user_id: int):
|
||||||
"""
|
"""Parse a Garmin wellness FIT file and upsert into health_metrics."""
|
||||||
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
|
|
||||||
Uses wellness_parser which handles standard FIT + Garmin proprietary messages.
|
|
||||||
"""
|
|
||||||
from app.services.wellness_parser import parse_wellness_fit as _parse
|
from app.services.wellness_parser import parse_wellness_fit as _parse
|
||||||
from app.core.database import SyncSessionLocal
|
from app.core.database import SyncSessionLocal
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
result = _parse(file_path)
|
try:
|
||||||
|
result = _parse(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e), "file": file_path}
|
||||||
|
|
||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
return {"status": "error", "error": result["error"], "file": file_path}
|
return {"status": "error", "error": result["error"], "file": file_path}
|
||||||
|
|
||||||
@@ -216,11 +229,13 @@ def parse_wellness_fit(file_path: str, user_id: int):
|
|||||||
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
|
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
|
||||||
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
|
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
|
||||||
steps, floors_climbed, active_calories, total_calories,
|
steps, floors_climbed, active_calories, total_calories,
|
||||||
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s)
|
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s,
|
||||||
|
sleep_score, sleep_start, sleep_end, sleep_stages)
|
||||||
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
|
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
|
||||||
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
|
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
|
||||||
:steps, :floors, :active_cal, :total_cal,
|
:steps, :floors, :active_cal, :total_cal,
|
||||||
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake)
|
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake,
|
||||||
|
:sleep_score, :sleep_start, :sleep_end, :sleep_stages::json)
|
||||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||||
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
||||||
@@ -233,12 +248,16 @@ def parse_wellness_fit(file_path: str, user_id: int):
|
|||||||
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||||
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
||||||
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
||||||
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
|
total_calories = GREATEST(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||||
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
|
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
|
||||||
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
|
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
|
||||||
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
|
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
|
||||||
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
|
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
|
||||||
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
|
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s),
|
||||||
|
sleep_score = COALESCE(EXCLUDED.sleep_score, health_metrics.sleep_score),
|
||||||
|
sleep_start = COALESCE(EXCLUDED.sleep_start, health_metrics.sleep_start),
|
||||||
|
sleep_end = COALESCE(EXCLUDED.sleep_end, health_metrics.sleep_end),
|
||||||
|
sleep_stages = COALESCE(EXCLUDED.sleep_stages, health_metrics.sleep_stages)
|
||||||
"""), {
|
"""), {
|
||||||
"user_id": user_id, "date": date_dt,
|
"user_id": user_id, "date": date_dt,
|
||||||
"resting_hr": data.get("resting_hr"),
|
"resting_hr": data.get("resting_hr"),
|
||||||
@@ -258,35 +277,35 @@ def parse_wellness_fit(file_path: str, user_id: int):
|
|||||||
"sleep_light": data.get("sleep_light_s"),
|
"sleep_light": data.get("sleep_light_s"),
|
||||||
"sleep_rem": data.get("sleep_rem_s"),
|
"sleep_rem": data.get("sleep_rem_s"),
|
||||||
"sleep_awake": data.get("sleep_awake_s"),
|
"sleep_awake": data.get("sleep_awake_s"),
|
||||||
|
"sleep_score": data.get("sleep_score"),
|
||||||
|
"sleep_start": data.get("sleep_start"),
|
||||||
|
"sleep_end": data.get("sleep_end"),
|
||||||
|
"sleep_stages": __import__('json').dumps(data.get("sleep_stages")) if data.get("sleep_stages") else None,
|
||||||
})
|
})
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"status": "ok", "days_processed": len(days), "file": file_path}
|
return {"status": "ok", "days_processed": len(days), "file": file_path}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="detect_route")
|
@celery_app.task(name="detect_route")
|
||||||
def detect_route(activity_id: int, user_id: int):
|
def detect_route(activity_id: int, user_id: int):
|
||||||
"""
|
"""Auto-detect and link activities to named routes."""
|
||||||
After importing an activity, check if it matches any existing named routes.
|
|
||||||
If two+ unassigned activities match each other, auto-create a named route.
|
|
||||||
"""
|
|
||||||
from app.services.route_matcher import routes_are_similar
|
from app.services.route_matcher import routes_are_similar
|
||||||
from app.core.database import SyncSessionLocal
|
from app.core.database import SyncSessionLocal
|
||||||
from app.models.user import Activity, NamedRoute
|
from app.models.user import Activity, NamedRoute
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
with SyncSessionLocal() as db:
|
with SyncSessionLocal() as db:
|
||||||
# Get the new activity
|
|
||||||
new_act = db.execute(
|
new_act = db.execute(
|
||||||
select(Activity).where(Activity.id == activity_id)
|
select(Activity).where(Activity.id == activity_id)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
if not new_act or not new_act.polyline:
|
if not new_act or not new_act.polyline:
|
||||||
return {"status": "no_polyline"}
|
return {"status": "no_polyline"}
|
||||||
|
|
||||||
# Already assigned to a route?
|
|
||||||
if new_act.named_route_id:
|
if new_act.named_route_id:
|
||||||
return {"status": "already_assigned"}
|
return {"status": "already_assigned"}
|
||||||
|
|
||||||
# Check against existing named routes first
|
|
||||||
routes = db.execute(
|
routes = db.execute(
|
||||||
select(NamedRoute).where(
|
select(NamedRoute).where(
|
||||||
NamedRoute.user_id == user_id,
|
NamedRoute.user_id == user_id,
|
||||||
@@ -298,12 +317,12 @@ def detect_route(activity_id: int, user_id: int):
|
|||||||
if route.reference_polyline and routes_are_similar(
|
if route.reference_polyline and routes_are_similar(
|
||||||
new_act.polyline, route.reference_polyline,
|
new_act.polyline, route.reference_polyline,
|
||||||
new_act.bounding_box, route.bounding_box,
|
new_act.bounding_box, route.bounding_box,
|
||||||
|
dist1=new_act.distance_m, dist2=route.distance_m,
|
||||||
):
|
):
|
||||||
new_act.named_route_id = route.id
|
new_act.named_route_id = route.id
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "matched_existing", "route_id": route.id}
|
return {"status": "matched_existing", "route_id": route.id}
|
||||||
|
|
||||||
# No existing route matched - check unassigned activities for a match
|
|
||||||
candidates = db.execute(
|
candidates = db.execute(
|
||||||
select(Activity).where(
|
select(Activity).where(
|
||||||
Activity.user_id == user_id,
|
Activity.user_id == user_id,
|
||||||
@@ -311,9 +330,8 @@ def detect_route(activity_id: int, user_id: int):
|
|||||||
Activity.named_route_id == None,
|
Activity.named_route_id == None,
|
||||||
Activity.id != activity_id,
|
Activity.id != activity_id,
|
||||||
Activity.polyline != None,
|
Activity.polyline != None,
|
||||||
# Within 20% distance
|
Activity.distance_m >= (new_act.distance_m or 0) * 0.95,
|
||||||
Activity.distance_m >= (new_act.distance_m or 0) * 0.8,
|
Activity.distance_m <= (new_act.distance_m or 0) * 1.05,
|
||||||
Activity.distance_m <= (new_act.distance_m or 0) * 1.2,
|
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|
||||||
@@ -321,8 +339,8 @@ def detect_route(activity_id: int, user_id: int):
|
|||||||
if routes_are_similar(
|
if routes_are_similar(
|
||||||
new_act.polyline, candidate.polyline,
|
new_act.polyline, candidate.polyline,
|
||||||
new_act.bounding_box, candidate.bounding_box,
|
new_act.bounding_box, candidate.bounding_box,
|
||||||
|
dist1=new_act.distance_m, dist2=candidate.distance_m,
|
||||||
):
|
):
|
||||||
# Auto-create a route from the older activity
|
|
||||||
older = candidate if candidate.start_time < new_act.start_time else new_act
|
older = candidate if candidate.start_time < new_act.start_time else new_act
|
||||||
newer = new_act if candidate.start_time < new_act.start_time else candidate
|
newer = new_act if candidate.start_time < new_act.start_time else candidate
|
||||||
|
|
||||||
@@ -400,52 +418,208 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
|
|||||||
import zipfile
|
import zipfile
|
||||||
import json
|
import json
|
||||||
from app.core.database import SyncSessionLocal
|
from app.core.database import SyncSessionLocal
|
||||||
from app.models.user import HealthMetric
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
INSERT_SQL = text("""
|
||||||
|
INSERT INTO health_metrics (user_id, date, resting_hr, max_hr_day, steps,
|
||||||
|
floors_climbed, active_calories, total_calories, avg_stress, spo2_avg)
|
||||||
|
VALUES (:user_id, :date, :resting_hr, :max_hr_day, :steps,
|
||||||
|
:floors, :active_cal, :total_cal, :stress, :spo2)
|
||||||
|
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||||
|
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||||
|
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
|
||||||
|
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||||
|
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
||||||
|
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
||||||
|
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||||
|
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||||
|
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg)
|
||||||
|
""")
|
||||||
|
|
||||||
|
def _extract_stress(item):
|
||||||
|
stress_data = item.get("allDayStress")
|
||||||
|
if not stress_data or not isinstance(stress_data, dict):
|
||||||
|
return item.get("averageStressLevel")
|
||||||
|
for agg in stress_data.get("aggregatorList", []):
|
||||||
|
if agg.get("type") == "TOTAL":
|
||||||
|
return agg.get("averageStressLevel")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _floors_from_item(item):
|
||||||
|
# UDS format reports meters; 1 floor = 3.048 m
|
||||||
|
meters = item.get("floorsAscendedInMeters")
|
||||||
|
if meters is not None:
|
||||||
|
return round(meters / 3.048)
|
||||||
|
return item.get("floorsAscended")
|
||||||
|
|
||||||
|
def _process_record(db, item):
|
||||||
|
date_str = item.get("calendarDate") or item.get("date")
|
||||||
|
if not date_str:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
date_dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
db.execute(INSERT_SQL, {
|
||||||
|
"user_id": user_id, "date": date_dt,
|
||||||
|
"resting_hr": item.get("restingHeartRate"),
|
||||||
|
"max_hr_day": item.get("maxHeartRate"),
|
||||||
|
"steps": item.get("totalSteps"),
|
||||||
|
"floors": _floors_from_item(item),
|
||||||
|
"active_cal": item.get("activeKilocalories"),
|
||||||
|
"total_cal": item.get("totalKilocalories"),
|
||||||
|
"stress": _extract_stress(item),
|
||||||
|
"spo2": item.get("avgSpo2"),
|
||||||
|
})
|
||||||
|
|
||||||
with SyncSessionLocal() as db:
|
with SyncSessionLocal() as db:
|
||||||
with zipfile.ZipFile(zip_path) as zf:
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
for name in zf.namelist():
|
for name in zf.namelist():
|
||||||
if "DailyMetrics" not in name or not name.endswith(".json"):
|
if not name.endswith(".json"):
|
||||||
|
continue
|
||||||
|
# Garmin Connect export stores daily summaries in UDSFile_*.json
|
||||||
|
# (DI-Connect-Aggregator). Older/alternative exports may use DailyMetrics.
|
||||||
|
is_uds = "UDSFile" in name
|
||||||
|
is_legacy = "DailyMetrics" in name
|
||||||
|
if not (is_uds or is_legacy):
|
||||||
continue
|
continue
|
||||||
with zf.open(name) as f:
|
with zf.open(name) as f:
|
||||||
try:
|
try:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
# UDS files are lists of daily records; legacy format is a single object
|
||||||
|
records = data if isinstance(data, list) else [data]
|
||||||
|
for item in records:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
_process_record(db, item)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
date_str = data.get("calendarDate") or data.get("date")
|
|
||||||
if not date_str:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
@celery_app.task(name="sync_garmin_connect_user")
|
||||||
date_dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
|
def sync_garmin_connect_user(user_id: int):
|
||||||
except ValueError:
|
"""Sync Garmin Connect data (activities + wellness) for one user."""
|
||||||
continue
|
from app.services.garmin_connect_sync import authenticate_garmin, sync_activities, sync_wellness
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from app.models.user import GarminConnectConfig
|
||||||
|
from app.core.config import settings
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import text as _text
|
with SyncSessionLocal() as db:
|
||||||
db.execute(_text("""
|
cfg = db.execute(
|
||||||
INSERT INTO health_metrics (user_id, date, resting_hr, steps,
|
select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)
|
||||||
floors_climbed, active_calories, total_calories, avg_stress, spo2_avg)
|
).scalar_one_or_none()
|
||||||
VALUES (:user_id, :date, :resting_hr, :steps,
|
|
||||||
:floors, :active_cal, :total_cal, :stress, :spo2)
|
if not cfg or not cfg.sync_enabled:
|
||||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
return {"status": "skipped"}
|
||||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
|
||||||
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
# Snapshot config values before any intermediate commits (commits expire ORM attrs)
|
||||||
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
email = cfg.email
|
||||||
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
password_enc = cfg.password_enc
|
||||||
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
|
token_store = cfg.token_store
|
||||||
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
last_sync_at = cfg.last_sync_at
|
||||||
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg)
|
sync_acts = cfg.sync_activities
|
||||||
"""), {
|
sync_well = cfg.sync_wellness
|
||||||
"user_id": user_id, "date": date_dt,
|
lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
|
||||||
"resting_hr": data.get("restingHeartRate"),
|
|
||||||
"steps": data.get("totalSteps"),
|
cfg.last_sync_status = "Connecting to Garmin..."
|
||||||
"floors": data.get("floorsAscended"),
|
db.commit()
|
||||||
"active_cal": data.get("activeKilocalories"),
|
|
||||||
"total_cal": data.get("totalKilocalories"),
|
try:
|
||||||
"stress": data.get("averageStressLevel"),
|
garmin, new_token = authenticate_garmin(email, password_enc, token_store)
|
||||||
"spo2": data.get("avgSpo2"),
|
except Exception as exc:
|
||||||
})
|
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||||
|
cfg.last_sync_status = f"Auth error: {exc}"
|
||||||
|
db.commit()
|
||||||
|
return {"status": "auth_error", "error": str(exc)}
|
||||||
|
|
||||||
|
if new_token:
|
||||||
|
cfg.token_store = new_token
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
activities_queued = 0
|
||||||
|
wellness_days = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def _set_status(text):
|
||||||
|
cfg.last_sync_status = text
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if sync_acts:
|
||||||
|
_set_status("Syncing activities...")
|
||||||
|
try:
|
||||||
|
activities_queued = sync_activities(
|
||||||
|
garmin, user_id, last_sync_at, db, settings.file_store_path,
|
||||||
|
lookback_days=lookback,
|
||||||
|
status_callback=_set_status,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"activities: {exc}")
|
||||||
|
|
||||||
|
if sync_well:
|
||||||
|
_set_status("Syncing wellness...")
|
||||||
|
try:
|
||||||
|
wellness_days = sync_wellness(
|
||||||
|
garmin, user_id, last_sync_at, db,
|
||||||
|
lookback_days=lookback,
|
||||||
|
status_callback=_set_status,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"wellness: {exc}")
|
||||||
|
db.rollback() # recover session so the final status commit can succeed
|
||||||
|
|
||||||
|
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||||
|
cfg.last_sync_status = (
|
||||||
|
f"OK — {activities_queued} activities queued, {wellness_days} wellness days synced"
|
||||||
|
if not errors else
|
||||||
|
f"Partial — {'; '.join(errors)}"
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "ok", "activities_queued": activities_queued, "wellness_days": wellness_days}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="sync_all_garmin_connect")
|
||||||
|
def sync_all_garmin_connect():
|
||||||
|
"""Hourly beat task: dispatch per-user sync for all enabled configs."""
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from app.models.user import GarminConnectConfig
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
configs = db.execute(
|
||||||
|
select(GarminConnectConfig).where(GarminConnectConfig.sync_enabled == True)
|
||||||
|
).scalars().all()
|
||||||
|
user_ids = [c.user_id for c in configs]
|
||||||
|
|
||||||
|
for uid in user_ids:
|
||||||
|
sync_garmin_connect_user.delay(uid)
|
||||||
|
|
||||||
|
return {"dispatched": len(user_ids)}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="recalculate_hr_zones_for_user")
|
||||||
|
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
|
||||||
|
"""Recalculate hr_zones for all of a user's activities using a new max HR."""
|
||||||
|
from app.services.fit_parser import calculate_hr_zones
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from app.models.user import Activity, ActivityDataPoint
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
activities = db.execute(
|
||||||
|
select(Activity).where(Activity.user_id == user_id)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
for activity in activities:
|
||||||
|
data_points = db.execute(
|
||||||
|
select(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity.id)
|
||||||
|
).scalars().all()
|
||||||
|
points_dicts = [{"heart_rate": dp.heart_rate} for dp in data_points]
|
||||||
|
new_zones = calculate_hr_zones(points_dicts, new_max_hr)
|
||||||
|
if new_zones:
|
||||||
|
activity.hr_zones = new_zones
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ httpx==0.27.0
|
|||||||
redis[hiredis]==5.0.4
|
redis[hiredis]==5.0.4
|
||||||
celery[redis]==5.4.0
|
celery[redis]==5.4.0
|
||||||
garmin-fit-sdk==21.195.0
|
garmin-fit-sdk==21.195.0
|
||||||
fitparse==1.2.0
|
|
||||||
gpxpy==1.6.2
|
gpxpy==1.6.2
|
||||||
numpy==1.26.4
|
numpy==1.26.4
|
||||||
scipy==1.13.0
|
scipy==1.13.0
|
||||||
@@ -24,3 +23,5 @@ aiofiles==23.2.1
|
|||||||
python-dateutil==2.9.0
|
python-dateutil==2.9.0
|
||||||
pytz==2024.1
|
pytz==2024.1
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
|
garminconnect==0.2.24
|
||||||
|
cryptography==42.0.8
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package.json ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_API_URL=/api
|
ARG VITE_API_URL=/api
|
||||||
|
|||||||
@@ -20,8 +20,7 @@
|
|||||||
"zustand": "^4.5.2",
|
"zustand": "^4.5.2",
|
||||||
"@tanstack/react-query": "^5.40.0",
|
"@tanstack/react-query": "^5.40.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3"
|
||||||
"@polyline-codec/core": "^2.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ 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'
|
||||||
|
import UsersPage from './pages/UsersPage'
|
||||||
|
|
||||||
function RequireAuth({ children }) {
|
function RequireAuth({ children }) {
|
||||||
const token = useAuthStore((s) => s.token)
|
const token = useAuthStore((s) => s.token)
|
||||||
@@ -25,16 +27,6 @@ export default function App() {
|
|||||||
if (token) fetchUser()
|
if (token) fetchUser()
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
const urlToken = params.get('token')
|
|
||||||
if (urlToken) {
|
|
||||||
localStorage.setItem('token', urlToken)
|
|
||||||
useAuthStore.setState({ token: urlToken })
|
|
||||||
window.history.replaceState({}, '', '/')
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
@@ -44,9 +36,11 @@ 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 />} />
|
||||||
|
<Route path="users" element={<UsersPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,55 +39,82 @@ 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({
|
||||||
|
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
|
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
|
||||||
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 trackRef = useRef(null)
|
const trackRef = useRef(null)
|
||||||
const tileLayerRef = useRef(null)
|
const tileLayerRef = useRef(null)
|
||||||
|
const polylineRef = useRef(polyline)
|
||||||
|
const sportTypeRef = useRef(sportType)
|
||||||
|
|
||||||
|
useEffect(() => { polylineRef.current = polyline }, [polyline])
|
||||||
|
useEffect(() => { sportTypeRef.current = sportType }, [sportType])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current || mapInstanceRef.current) return
|
if (!mapRef.current || mapInstanceRef.current) return
|
||||||
mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true })
|
|
||||||
const tile = TILE_LAYERS['dark']
|
mapInstanceRef.current = L.map(mapRef.current, {
|
||||||
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
|
zoomControl: true,
|
||||||
.addTo(mapInstanceRef.current)
|
attributionControl: true,
|
||||||
return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null }
|
})
|
||||||
|
|
||||||
|
const tile = TILE_LAYERS.dark
|
||||||
|
tileLayerRef.current = L.tileLayer(tile.url, {
|
||||||
|
attribution: tile.attribution,
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(mapInstanceRef.current)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mapInstanceRef.current?.remove()
|
||||||
|
mapInstanceRef.current = null
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Switch tile layer when mapType changes
|
|
||||||
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.dark
|
||||||
if (tileLayerRef.current) {
|
if (tileLayerRef.current) tileLayerRef.current.remove()
|
||||||
tileLayerRef.current.remove()
|
tileLayerRef.current = L.tileLayer(tile.url, {
|
||||||
}
|
attribution: tile.attribution,
|
||||||
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
|
maxZoom: 19,
|
||||||
.addTo(mapInstanceRef.current)
|
}).addTo(mapInstanceRef.current)
|
||||||
|
drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef)
|
||||||
}, [mapType])
|
}, [mapType])
|
||||||
|
|
||||||
// Draw route
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapInstanceRef.current || !polyline) return
|
if (!mapInstanceRef.current) return
|
||||||
if (trackRef.current) trackRef.current.remove()
|
drawRoute(mapInstanceRef.current, polyline, sportType, trackRef)
|
||||||
const coords = decodePolyline(polyline)
|
|
||||||
if (!coords.length) return
|
|
||||||
trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 })
|
|
||||||
.addTo(mapInstanceRef.current)
|
|
||||||
mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
|
|
||||||
if (coords.length > 0) {
|
|
||||||
const dot = (color) => L.divIcon({
|
|
||||||
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
|
||||||
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
|
||||||
})
|
|
||||||
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(mapInstanceRef.current)
|
|
||||||
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(mapInstanceRef.current)
|
|
||||||
}
|
|
||||||
}, [polyline, sportType])
|
}, [polyline, sportType])
|
||||||
|
|
||||||
// Position marker on timeline hover
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
|
if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
|
||||||
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
|
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
|
||||||
if (!point?.latitude || !point?.longitude) return
|
if (!point?.latitude || !point?.longitude) return
|
||||||
if (markerRef.current) {
|
if (markerRef.current) {
|
||||||
@@ -97,7 +124,8 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
|||||||
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
|
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
|
||||||
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
||||||
})
|
})
|
||||||
markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current)
|
markerRef.current = L.marker([point.latitude, point.longitude], { icon })
|
||||||
|
.addTo(mapInstanceRef.current)
|
||||||
}
|
}
|
||||||
}, [hoveredDistance, dataPoints])
|
}, [hoveredDistance, dataPoints])
|
||||||
|
|
||||||
|
|||||||
@@ -62,16 +62,21 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
const domains = useMemo(() => {
|
const domains = useMemo(() => {
|
||||||
const result = {}
|
const result = {}
|
||||||
for (const m of activeMetricConfigs) {
|
for (const m of activeMetricConfigs) {
|
||||||
const vals = chartData.map(p => p[m.key]).filter(v => v != null)
|
let vals = chartData.map(p => p[m.key]).filter(v => v != null)
|
||||||
if (!vals.length) continue
|
if (!vals.length) continue
|
||||||
|
// Clamp GPS speed outliers (spikes cause absurd pace labels like 0:01/km)
|
||||||
|
if (m.key === 'speed_ms') {
|
||||||
|
const speedCap = sportType === 'cycling' ? 25 : 12
|
||||||
|
vals = vals.filter(v => v > 0 && v <= speedCap)
|
||||||
|
if (!vals.length) continue
|
||||||
|
}
|
||||||
const min = Math.min(...vals)
|
const min = Math.min(...vals)
|
||||||
const max = Math.max(...vals)
|
const max = Math.max(...vals)
|
||||||
const pad = (max - min) * 0.1 || 1
|
const pad = (max - min) * 0.1 || 1
|
||||||
// For elevation, don't start from 0 - show actual range
|
|
||||||
result[m.key] = [min - pad, max + pad]
|
result[m.key] = [min - pad, max + pad]
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}, [chartData, activeMetricConfigs])
|
}, [chartData, activeMetricConfigs, sportType])
|
||||||
|
|
||||||
if (!chartData.length) {
|
if (!chartData.length) {
|
||||||
return (
|
return (
|
||||||
@@ -95,7 +100,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
|
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={100}>
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
|
<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="distance_m"
|
||||||
@@ -115,6 +120,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
width={40}
|
width={40}
|
||||||
tickFormatter={v => {
|
tickFormatter={v => {
|
||||||
if (metric.key === 'speed_ms') {
|
if (metric.key === 'speed_ms') {
|
||||||
|
if (v <= 0 || v > 25) return ''
|
||||||
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
||||||
const spm = 1000 / v
|
const spm = 1000 / v
|
||||||
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
|
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ const nav = [
|
|||||||
{ 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: '⚙️' },
|
||||||
|
{ to: '/users', label: 'Users', icon: '👥', adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
@@ -31,7 +33,7 @@ export default function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 py-4 overflow-y-auto">
|
<nav className="flex-1 py-4 overflow-y-auto">
|
||||||
{nav.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}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { sportColor } from '../../utils/format'
|
||||||
|
|
||||||
|
function decodePolyline(encoded) {
|
||||||
|
const coords = []
|
||||||
|
let index = 0, lat = 0, lng = 0
|
||||||
|
while (index < encoded.length) {
|
||||||
|
let b, shift = 0, result = 0
|
||||||
|
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
||||||
|
lat += (result & 1) ? ~(result >> 1) : result >> 1
|
||||||
|
shift = 0; result = 0
|
||||||
|
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
||||||
|
lng += (result & 1) ? ~(result >> 1) : result >> 1
|
||||||
|
coords.push([lat / 1e5, lng / 1e5])
|
||||||
|
}
|
||||||
|
return coords
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineDist([lat1, lng1], [lat2, lng2]) {
|
||||||
|
const R = 6371000
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||||
|
const dLng = (lng2 - lng1) * Math.PI / 180
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal viewBox dimensions — path is always drawn into this space, SVG scales it
|
||||||
|
const VW = 100
|
||||||
|
const VH = 80
|
||||||
|
const PAD = 6
|
||||||
|
|
||||||
|
function buildPaths(polyline, segStartM, segEndM) {
|
||||||
|
if (!polyline) return null
|
||||||
|
const coords = decodePolyline(polyline)
|
||||||
|
if (coords.length < 2) return null
|
||||||
|
|
||||||
|
const lats = coords.map(c => c[0])
|
||||||
|
const lngs = coords.map(c => c[1])
|
||||||
|
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||||
|
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||||
|
const latRange = maxLat - minLat || 0.001
|
||||||
|
const lngRange = maxLng - minLng || 0.001
|
||||||
|
|
||||||
|
const drawW = VW - PAD * 2
|
||||||
|
const drawH = VH - PAD * 2
|
||||||
|
const scale = Math.min(drawW / lngRange, drawH / latRange)
|
||||||
|
const offX = PAD + (drawW - lngRange * scale) / 2
|
||||||
|
const offY = PAD + (drawH - latRange * scale) / 2
|
||||||
|
|
||||||
|
const toXY = ([lat, lng]) => [
|
||||||
|
offX + (lng - minLng) * scale,
|
||||||
|
offY + (maxLat - lat) * scale,
|
||||||
|
]
|
||||||
|
|
||||||
|
const fullPath = coords.map((c, i) => {
|
||||||
|
const [x, y] = toXY(c)
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
}).join(' ')
|
||||||
|
|
||||||
|
if (segStartM == null || segEndM == null) return { fullPath, segPath: null }
|
||||||
|
|
||||||
|
// Compute cumulative distances to find segment slice
|
||||||
|
const cumDist = [0]
|
||||||
|
for (let i = 1; i < coords.length; i++) {
|
||||||
|
cumDist.push(cumDist[i - 1] + haversineDist(coords[i - 1], coords[i]))
|
||||||
|
}
|
||||||
|
const totalDist = cumDist[cumDist.length - 1] || 1
|
||||||
|
|
||||||
|
// Interpolate a point at a given distance along the route
|
||||||
|
const interpAt = (targetM) => {
|
||||||
|
for (let i = 1; i < cumDist.length; i++) {
|
||||||
|
if (cumDist[i] >= targetM || i === cumDist.length - 1) {
|
||||||
|
const t = cumDist[i] === cumDist[i - 1] ? 0 : (targetM - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1])
|
||||||
|
const lat = coords[i - 1][0] + t * (coords[i][0] - coords[i - 1][0])
|
||||||
|
const lng = coords[i - 1][1] + t * (coords[i][1] - coords[i - 1][1])
|
||||||
|
return [lat, lng]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coords[coords.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedStart = Math.max(0, Math.min(segStartM, totalDist))
|
||||||
|
const clampedEnd = Math.max(0, Math.min(segEndM, totalDist))
|
||||||
|
|
||||||
|
// Collect segment points: interpolated start + all interior coords + interpolated end
|
||||||
|
const segCoords = [interpAt(clampedStart)]
|
||||||
|
for (let i = 0; i < coords.length; i++) {
|
||||||
|
if (cumDist[i] > clampedStart && cumDist[i] < clampedEnd) {
|
||||||
|
segCoords.push(coords[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
segCoords.push(interpAt(clampedEnd))
|
||||||
|
|
||||||
|
const segPath = segCoords.map((c, i) => {
|
||||||
|
const [x, y] = toXY(c)
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
}).join(' ')
|
||||||
|
|
||||||
|
return { fullPath, segPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60, segmentStartM, segmentEndM }) {
|
||||||
|
const paths = useMemo(
|
||||||
|
() => buildPaths(polyline, segmentStartM, segmentEndM),
|
||||||
|
[polyline, segmentStartM, segmentEndM],
|
||||||
|
)
|
||||||
|
|
||||||
|
const svgProps = {
|
||||||
|
viewBox: `0 0 ${VW} ${VH}`,
|
||||||
|
preserveAspectRatio: 'xMidYMid meet',
|
||||||
|
className: 'rounded overflow-hidden block',
|
||||||
|
style: { background: '#111827', width, height },
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paths) return (
|
||||||
|
<svg {...svgProps}>
|
||||||
|
<text x={VW / 2} y={VH / 2} textAnchor="middle" dominantBaseline="middle" fill="#374151" fontSize="10">—</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseColor = paths.segPath ? '#374151' : sportColor(sportType)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg {...svgProps}>
|
||||||
|
<path d={paths.fullPath} fill="none" stroke={baseColor} strokeWidth={paths.segPath ? 1.5 : 2}
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
{paths.segPath && (
|
||||||
|
<path d={paths.segPath} fill="none" stroke="#f97316" strokeWidth="3"
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
|
||||||
|
// Read token from URL params synchronously at module load time,
|
||||||
|
// before any component renders. This handles PocketID OAuth callbacks.
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const urlToken = params.get('token')
|
||||||
|
if (urlToken) {
|
||||||
|
localStorage.setItem('token', urlToken)
|
||||||
|
window.history.replaceState({}, '', '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialToken = urlToken || localStorage.getItem('token')
|
||||||
|
|
||||||
export const useAuthStore = create((set) => ({
|
export const useAuthStore = create((set) => ({
|
||||||
token: localStorage.getItem('token'),
|
token: initialToken,
|
||||||
user: null,
|
user: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
||||||
login: async (username, password) => {
|
login: async (username, password) => {
|
||||||
set({ isLoading: true })
|
set({ isLoading: true })
|
||||||
try {
|
try {
|
||||||
@@ -23,12 +33,10 @@ export const useAuthStore = create((set) => ({
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
set({ token: null, user: null })
|
set({ token: null, user: null })
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchUser: async () => {
|
fetchUser: async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/auth/me')
|
const { data } = await api.get('/auth/me')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { format } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||||
@@ -10,24 +11,38 @@ import {
|
|||||||
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||||
|
|
||||||
export default function ActivitiesPage() {
|
export default function ActivitiesPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [sport, setSport] = useState('all')
|
const [sport, setSport] = useState('all')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const fromParam = searchParams.get('from')
|
||||||
|
const toParam = searchParams.get('to')
|
||||||
|
|
||||||
const { data: activities, isLoading } = useQuery({
|
const { data: activities, isLoading } = useQuery({
|
||||||
queryKey: ['activities', sport, page],
|
queryKey: ['activities', sport, page, fromParam, toParam],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get('/activities/', {
|
api.get('/activities/', {
|
||||||
params: {
|
params: {
|
||||||
sport_type: sport === 'all' ? undefined : sport,
|
sport_type: sport === 'all' ? undefined : sport,
|
||||||
page,
|
page,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
|
from_date: fromParam ? new Date(fromParam).toISOString() : undefined,
|
||||||
|
to_date: toParam ? new Date(toParam + 'T23:59:59').toISOString() : undefined,
|
||||||
},
|
},
|
||||||
}).then(r => r.data),
|
}).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: ytdStats } = useQuery({
|
||||||
|
queryKey: ['ytd-stats'],
|
||||||
|
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearDateFilter = () => navigate('/activities')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||||||
<Link
|
<Link
|
||||||
to="/upload"
|
to="/upload"
|
||||||
@@ -37,6 +52,28 @@ export default function ActivitiesPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* YTD stats */}
|
||||||
|
{ytdStats && (
|
||||||
|
<div className="flex gap-4 mb-4 text-sm">
|
||||||
|
{ytdStats.running_km > 0 && (
|
||||||
|
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
|
||||||
|
)}
|
||||||
|
{ytdStats.cycling_km > 0 && (
|
||||||
|
<span className="text-orange-400">🚴 {ytdStats.cycling_km.toFixed(0)} km this year</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date filter chip */}
|
||||||
|
{fromParam && (
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<span className="text-xs bg-blue-600/20 text-blue-300 border border-blue-500/30 px-3 py-1 rounded-full">
|
||||||
|
Week of {format(new Date(fromParam), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
<button onClick={clearDateFilter} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">✕ Clear</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sport filter */}
|
{/* Sport filter */}
|
||||||
<div className="flex gap-2 mb-6 flex-wrap">
|
<div className="flex gap-2 mb-6 flex-wrap">
|
||||||
{SPORTS.map(s => (
|
{SPORTS.map(s => (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } 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 } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'
|
||||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } 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 {
|
import {
|
||||||
@@ -9,7 +9,69 @@ import {
|
|||||||
formatDate, sportIcon, formatSleep,
|
formatDate, sportIcon, formatSleep,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
|
||||||
|
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 MiniBodyBattery({ bb }) {
|
||||||
|
if (!bb?.end_level && !bb?.charged) return null
|
||||||
|
const { charged, drained, start_level, end_level, values } = bb
|
||||||
|
const color = bbLevelColor(end_level)
|
||||||
|
const sparkData = Array.isArray(values)
|
||||||
|
? values.map(([ts, level]) => ({ ts, level }))
|
||||||
|
: []
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-3 flex-wrap">
|
||||||
|
{end_level != null && (
|
||||||
|
<span className="text-3xl font-bold" style={{ color }}>{Math.round(end_level)}</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{start_level != null && end_level != null && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{start_level} → {end_level} today</p>
|
||||||
|
)}
|
||||||
|
{sparkData.length >= 2 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<ResponsiveContainer width="100%" height={60}>
|
||||||
|
<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
|
||||||
|
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11 }}
|
||||||
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
|
formatter={v => [`${Math.round(v)}`, 'Battery']}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function WeeklyChart({ activities }) {
|
function WeeklyChart({ activities }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
if (!activities?.length) return (
|
if (!activities?.length) return (
|
||||||
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
|
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
|
||||||
)
|
)
|
||||||
@@ -23,26 +85,39 @@ function WeeklyChart({ activities }) {
|
|||||||
|
|
||||||
const data = weeks.map(weekStart => {
|
const data = weeks.map(weekStart => {
|
||||||
const weekKey = format(weekStart, 'MMM d')
|
const weekKey = format(weekStart, 'MMM d')
|
||||||
const weekEnd = new Date(weekStart)
|
const weekEnd = addDays(weekStart, 7)
|
||||||
weekEnd.setDate(weekEnd.getDate() + 7)
|
|
||||||
const km = activities
|
const km = activities
|
||||||
.filter(a => {
|
.filter(a => {
|
||||||
const t = new Date(a.start_time)
|
const t = new Date(a.start_time)
|
||||||
return t >= weekStart && t < weekEnd
|
return t >= weekStart && t < weekEnd
|
||||||
})
|
})
|
||||||
.reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
|
.reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
|
||||||
return { week: weekKey, km: parseFloat(km.toFixed(2)) }
|
return {
|
||||||
|
week: weekKey,
|
||||||
|
km: parseFloat(km.toFixed(2)),
|
||||||
|
weekStartISO: format(weekStart, 'yyyy-MM-dd'),
|
||||||
|
weekEndISO: format(weekEnd, 'yyyy-MM-dd'),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleBarClick = (entry) => {
|
||||||
|
if (entry?.activePayload?.[0]?.payload) {
|
||||||
|
const { weekStartISO, weekEndISO } = entry.activePayload[0].payload
|
||||||
|
navigate(`/activities?from=${weekStartISO}&to=${weekEndISO}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}>
|
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}
|
||||||
|
onClick={handleBarClick} style={{ cursor: 'pointer' }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||||
tickFormatter={v => `${v.toFixed(0)}`} />
|
tickFormatter={v => `${v.toFixed(0)}`} />
|
||||||
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||||
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} />
|
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']}
|
||||||
|
cursor={{ fill: 'rgba(59,130,246,0.1)' }} />
|
||||||
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -73,8 +148,12 @@ export default function DashboardPage() {
|
|||||||
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
|
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: ytdStats } = useQuery({
|
||||||
|
queryKey: ['ytd-stats'],
|
||||||
|
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
const latest = healthSummary?.latest
|
const latest = healthSummary?.latest
|
||||||
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -84,19 +163,23 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<StatCard label="Recent activities" value={recentActivities?.length ?? 0} />
|
<StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
|
||||||
<StatCard label="Total distance" value={formatDistance(totalDistance)} accent="blue" />
|
<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(latest?.resting_hr)} accent="red" />
|
||||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
|
||||||
<WeeklyChart activities={allActivities} />
|
<WeeklyChart activities={allActivities} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
<div className="lg:col-span-1">
|
||||||
|
<MiniBodyBattery bb={latest?.body_battery} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 ? (
|
{latest ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -9,14 +9,135 @@ import api from '../utils/api'
|
|||||||
import { formatSleep, sportIcon } from '../utils/format'
|
import { formatSleep, sportIcon } from '../utils/format'
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
{ label: '1W', days: 7 },
|
{ label: '1W', days: 7 },
|
||||||
{ label: '2W', days: 14 },
|
{ label: '2W', days: 14 },
|
||||||
{ label: '1M', days: 30 },
|
{ label: '1M', days: 30 },
|
||||||
{ label: '3M', days: 90 },
|
{ label: '3M', days: 90 },
|
||||||
{ label: '6M', days: 180 },
|
{ label: '6M', days: 180 },
|
||||||
{ label: '1Y', days: 365 },
|
{ label: '1Y', days: 365 },
|
||||||
|
{ label: '3Y', days: 1095 },
|
||||||
|
{ label: '5Y', days: 1825 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ── VO2 Max gauge ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Garmin/Cooper Institute VO2 max thresholds
|
||||||
|
// [maxAge, [fair_min, good_min, excellent_min, superior_min]]
|
||||||
|
// value < fair_min → Poor; >= superior_min → Superior
|
||||||
|
const VO2_MALE = [
|
||||||
|
[29, [41.7, 45.4, 51.1, 55.4]],
|
||||||
|
[39, [40.5, 44.0, 48.3, 54.0]],
|
||||||
|
[49, [38.5, 42.4, 46.4, 52.5]],
|
||||||
|
[59, [35.6, 39.2, 43.4, 48.9]],
|
||||||
|
[69, [32.3, 35.5, 39.5, 45.7]],
|
||||||
|
[Infinity, [29.4, 32.3, 36.7, 42.1]],
|
||||||
|
]
|
||||||
|
const VO2_FEMALE = [
|
||||||
|
[29, [36.1, 39.5, 43.9, 49.6]],
|
||||||
|
[39, [34.4, 37.8, 42.4, 47.4]],
|
||||||
|
[49, [33.0, 36.3, 39.7, 45.3]],
|
||||||
|
[59, [30.1, 33.0, 36.7, 41.1]],
|
||||||
|
[69, [27.5, 30.0, 33.0, 37.8]],
|
||||||
|
[Infinity, [25.9, 28.1, 30.9, 36.7]],
|
||||||
|
]
|
||||||
|
const VO2_CATEGORIES = [
|
||||||
|
{ label: 'Poor', color: '#ef4444' },
|
||||||
|
{ label: 'Fair', color: '#f97316' },
|
||||||
|
{ label: 'Good', color: '#22c55e' },
|
||||||
|
{ label: 'Excellent', color: '#3b82f6' },
|
||||||
|
{ label: 'Superior', color: '#a855f7' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function getVo2Category(value, age, sex) {
|
||||||
|
const table = sex === 'female' ? VO2_FEMALE : VO2_MALE
|
||||||
|
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
||||||
|
const thresholds = row[1]
|
||||||
|
// thresholds are lower-bounds: count how many the value meets or exceeds
|
||||||
|
const idx = thresholds.reduce((n, t) => value >= t ? n + 1 : n, 0)
|
||||||
|
return VO2_CATEGORIES[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
|
||||||
|
const MIN = 30, MAX = 70
|
||||||
|
// cx/cy = centre of the semicircle; arc goes left→top→right (sweep=1, clockwise in SVG)
|
||||||
|
const cx = 70, cy = 74, r = 50, sw = 11
|
||||||
|
|
||||||
|
const age = birthYear ? new Date().getFullYear() - birthYear : 40
|
||||||
|
|
||||||
|
// Standard-math angle: PI = left (VO2 30), 0 = right (VO2 70)
|
||||||
|
const toAngle = v => Math.PI * (1 - Math.max(0, Math.min(1, (v - MIN) / (MAX - MIN))))
|
||||||
|
|
||||||
|
// SVG coordinates for a VO2 value at a given radius from centre
|
||||||
|
const toXY = (v, radius = r) => {
|
||||||
|
const a = toAngle(v)
|
||||||
|
return [cx + radius * Math.cos(a), cy - radius * Math.sin(a)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arc path from VO2 v1 to v2; sweep=1 → clockwise = upper semicircle in SVG
|
||||||
|
const arc = (v1, v2, radius = r) => {
|
||||||
|
const [x1, y1] = toXY(v1, radius)
|
||||||
|
const [x2, y2] = toXY(v2, radius)
|
||||||
|
const large = 0 // gauge spans 180°, so no segment ever exceeds 180°
|
||||||
|
return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${radius} ${radius} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACSM category boundaries for this user's age/sex
|
||||||
|
const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE
|
||||||
|
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
||||||
|
const thresholds = row[1]
|
||||||
|
const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands
|
||||||
|
|
||||||
|
const cat = value != null ? getVo2Category(value, age, biologicalSex) : null
|
||||||
|
|
||||||
|
// White arrow: tip lands exactly at the arc centre-line at the value's angle;
|
||||||
|
// base extends outside the track — unambiguously marks the precise position.
|
||||||
|
const arrowPts = value != null ? (() => {
|
||||||
|
const a = toAngle(Math.max(MIN, Math.min(MAX, value)))
|
||||||
|
const tipR = r // tip at centre of the coloured track
|
||||||
|
const baseR = r + sw / 2 + 9 // base well outside the outer edge
|
||||||
|
const s = 0.09 // half-spread ≈ 5° — narrow for precision
|
||||||
|
const tipX = cx + tipR * Math.cos(a), tipY = cy - tipR * Math.sin(a)
|
||||||
|
const b1x = cx + baseR * Math.cos(a + s), b1y = cy - baseR * Math.sin(a + s)
|
||||||
|
const b2x = cx + baseR * Math.cos(a - s), b2y = cy - baseR * Math.sin(a - s)
|
||||||
|
return `${tipX.toFixed(1)},${tipY.toFixed(1)} ${b1x.toFixed(1)},${b1y.toFixed(1)} ${b2x.toFixed(1)},${b2y.toFixed(1)}`
|
||||||
|
})() : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<svg width="140" height="92" viewBox="0 0 140 92">
|
||||||
|
{/* Dark background track, slightly wider than the colour bands */}
|
||||||
|
<path d={arc(MIN, MAX)} stroke="#1f2937" strokeWidth={sw + 4} fill="none" strokeLinecap="butt" />
|
||||||
|
|
||||||
|
{/* Full-brightness ACSM colour bands */}
|
||||||
|
{VO2_CATEGORIES.map((c, i) => {
|
||||||
|
const v1 = Math.max(bounds[i], MIN)
|
||||||
|
const v2 = Math.min(bounds[i + 1], MAX)
|
||||||
|
if (v2 <= v1) return null
|
||||||
|
return (
|
||||||
|
<path key={i} d={arc(v1, v2)}
|
||||||
|
stroke={c.color} strokeWidth={sw} fill="none" strokeLinecap="butt" />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* White arrow: tip at exact value position on arc, base pointing outward */}
|
||||||
|
{arrowPts && <polygon points={arrowPts} fill="white" />}
|
||||||
|
|
||||||
|
{/* VO2 number, coloured by category */}
|
||||||
|
<text x={cx} y={cy - 6} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fontSize="21" fontWeight="700" fill={cat?.color ?? '#6b7280'}>
|
||||||
|
{value != null ? value.toFixed(1) : '--'}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Category label */}
|
||||||
|
<text x={cx} y={cy + 11} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fontSize="9" fill="#9ca3af">
|
||||||
|
{cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12,
|
background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12,
|
||||||
}
|
}
|
||||||
@@ -304,7 +425,7 @@ function NavArrow({ onClick, disabled, children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, onOlder, onNewer, hasOlder, hasNewer }) {
|
function DailySnapshot({ day, 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>
|
||||||
@@ -341,9 +462,9 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sleep (wide) + Heart / HRV */}
|
{/* Sleep (wide) + Heart / HRV */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
|
||||||
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
||||||
{day.sleep_score != null && (
|
{day.sleep_score != null && (
|
||||||
@@ -396,56 +517,58 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Heart & HRV</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart & HRV</h3>
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
<div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
||||||
<span className="text-3xl font-bold text-rose-400">
|
<div className="flex items-baseline gap-1.5">
|
||||||
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
|
<span className="text-3xl font-bold text-rose-400">
|
||||||
</span>
|
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
|
||||||
<span className="text-sm text-gray-500">bpm</span>
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">bpm</span>
|
||||||
|
</div>
|
||||||
|
{avg30?.resting_hr && day.resting_hr && (
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
30d avg {Math.round(avg30.resting_hr)}
|
||||||
|
{day.resting_hr < avg30.resting_hr
|
||||||
|
? <span className="text-green-400 ml-1">↓</span>
|
||||||
|
: day.resting_hr > avg30.resting_hr
|
||||||
|
? <span className="text-red-400 ml-1">↑</span>
|
||||||
|
: null}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{avg30?.resting_hr && day.resting_hr && (
|
<div>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
|
||||||
30d avg {Math.round(avg30.resting_hr)} bpm
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||||
{day.resting_hr < avg30.resting_hr
|
<span className="text-3xl font-bold text-violet-400">
|
||||||
? <span className="text-green-400 ml-1">↓</span>
|
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
|
||||||
: day.resting_hr > avg30.resting_hr
|
</span>
|
||||||
? <span className="text-red-400 ml-1">↑</span>
|
<span className="text-sm text-gray-500">ms</span>
|
||||||
: null}
|
</div>
|
||||||
</p>
|
<div className="mt-0.5"><HrvBadge status={day.hrv_status} /></div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
|
|
||||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
|
||||||
<span className="text-3xl font-bold text-violet-400">
|
|
||||||
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-500">ms</span>
|
|
||||||
<HrvBadge status={day.hrv_status} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{day.avg_hr_day && (
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-xl font-semibold text-orange-400">{Math.round(day.avg_hr_day)}</span>
|
<span className="text-xl font-semibold text-orange-400">
|
||||||
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max bpm</span>}
|
{day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'}
|
||||||
|
</span>
|
||||||
|
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{day.weight_kg && (
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
||||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||||
<span className="text-xl font-semibold text-emerald-400">{day.weight_kg.toFixed(1)}</span>
|
<span className="text-xl font-semibold text-emerald-400">
|
||||||
<span className="text-xs text-gray-500">kg</span>
|
{day.weight_kg ? day.weight_kg.toFixed(1) : '--'}
|
||||||
|
</span>
|
||||||
|
{day.weight_kg && <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>}
|
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -519,14 +642,16 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
{stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>}
|
{stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>}
|
||||||
</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 flex flex-col">
|
||||||
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<span className="text-2xl font-bold text-blue-400">
|
<Vo2MaxGauge
|
||||||
{(day.vo2max ?? latestVo2max) != null ? (day.vo2max ?? latestVo2max).toFixed(1) : '--'}
|
value={(day.vo2max ?? latestVo2max) ?? null}
|
||||||
</span>
|
birthYear={birthYear}
|
||||||
|
biologicalSex={biologicalSex}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
|
{day.fitness_age && <p className="text-xs text-gray-500 mt-1 text-center">Fitness age {day.fitness_age}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -637,12 +762,17 @@ export default function HealthPage() {
|
|||||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: profile } = useQuery({
|
||||||
|
queryKey: ['profile'],
|
||||||
|
queryFn: () => api.get('/profile/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
// Full history for snapshot navigation.
|
// Full history for snapshot navigation.
|
||||||
// Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically.
|
// Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically.
|
||||||
const { data: allDays } = useQuery({
|
const { data: allDays } = useQuery({
|
||||||
queryKey: ['health-metrics', 'all'],
|
queryKey: ['health-metrics', 'all'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get('/health-metrics/', { params: { limit: 365 } })
|
api.get('/health-metrics/', { params: { limit: 2000 } })
|
||||||
.then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
|
.then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -721,6 +851,8 @@ export default function HealthPage() {
|
|||||||
sleepStages={intradayData?.sleep_stages}
|
sleepStages={intradayData?.sleep_stages}
|
||||||
activities={dayActivities}
|
activities={dayActivities}
|
||||||
latestVo2max={latestVo2max}
|
latestVo2max={latestVo2max}
|
||||||
|
birthYear={profile?.birth_year}
|
||||||
|
biologicalSex={profile?.biological_sex}
|
||||||
onOlder={goOlder}
|
onOlder={goOlder}
|
||||||
onNewer={goNewer}
|
onNewer={goNewer}
|
||||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||||
@@ -857,6 +989,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)}
|
||||||
|
connectNulls showDots
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,10 +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 [error, setError] = useState('')
|
const authError = new URLSearchParams(window.location.search).get('auth_error')
|
||||||
|
const [error, setError] = useState(AUTH_ERRORS[authError] || '')
|
||||||
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),
|
||||||
@@ -74,7 +95,7 @@ export default function ProfilePage() {
|
|||||||
}, [recentMetrics])
|
}, [recentMetrics])
|
||||||
|
|
||||||
// HR / measurements form
|
// HR / measurements form
|
||||||
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '' })
|
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '' })
|
||||||
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)
|
||||||
@@ -83,6 +104,7 @@ export default function ProfilePage() {
|
|||||||
max_heart_rate: profile.max_heart_rate || '',
|
max_heart_rate: profile.max_heart_rate || '',
|
||||||
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 || '',
|
||||||
})
|
})
|
||||||
}, [profile])
|
}, [profile])
|
||||||
|
|
||||||
@@ -204,10 +226,10 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PocketID config
|
// PocketID config
|
||||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
|
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
|
||||||
const [pidSaved, setPidSaved] = useState(false)
|
const [pidSaved, setPidSaved] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
|
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '', allowed_group: pocketidConfig.allowed_group || '' })
|
||||||
}, [pocketidConfig])
|
}, [pocketidConfig])
|
||||||
const savePocketID = useMutation({
|
const savePocketID = useMutation({
|
||||||
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
|
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
|
||||||
@@ -246,6 +268,21 @@ export default function ProfilePage() {
|
|||||||
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
|
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
|
||||||
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
|
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Biological sex" hint="Used for VO2 max fitness category thresholds">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['male', 'female'].map(s => (
|
||||||
|
<button key={s} type="button"
|
||||||
|
onClick={() => setHrForm(f => ({ ...f, biological_sex: f.biological_sex === s ? '' : s }))}
|
||||||
|
className={`flex-1 py-2 rounded-lg text-sm border transition-colors capitalize ${
|
||||||
|
hrForm.biological_sex === s
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: 'border-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
|
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
|
||||||
@@ -268,7 +305,7 @@ export default function ProfilePage() {
|
|||||||
<SaveButton
|
<SaveButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const data = Object.fromEntries(
|
const data = Object.fromEntries(
|
||||||
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
|
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, k === 'biological_sex' ? v : parseFloat(v)])
|
||||||
)
|
)
|
||||||
maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate
|
maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate
|
||||||
updateProfile.mutate(data)
|
updateProfile.mutate(data)
|
||||||
@@ -309,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">
|
||||||
@@ -455,6 +513,10 @@ export default function ProfilePage() {
|
|||||||
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
|
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
|
||||||
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
|
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Allowed PocketID group" hint="Only members of this PocketID group may sign in. Leave blank to allow all.">
|
||||||
|
<Input value={pidForm.allowed_group} placeholder="e.g. milevault-users"
|
||||||
|
onChange={e => setPidForm(f => ({ ...f, allowed_group: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
{pocketidConfig?.enabled && (
|
{pocketidConfig?.enabled && (
|
||||||
<p className="text-xs text-green-400">✓ PocketID is currently active</p>
|
<p className="text-xs text-green-400">✓ PocketID is currently active</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,38 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
|
||||||
function UploadZone({ title, description, accept, endpoint, icon }) {
|
function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||||
const [tasks, setTasks] = useState([])
|
const [tasks, setTasks] = useState([])
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const intervalsRef = useRef({})
|
||||||
|
|
||||||
|
const pollTask = useCallback((taskId) => {
|
||||||
|
if (intervalsRef.current[taskId]) return
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/upload/task/${taskId}`)
|
||||||
|
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
|
||||||
|
clearInterval(intervalsRef.current[taskId])
|
||||||
|
delete intervalsRef.current[taskId]
|
||||||
|
setTasks(ts => ts.map(t =>
|
||||||
|
t.task_id === taskId ? { ...t, status: data.status === 'SUCCESS' ? 'done' : 'failed' } : t
|
||||||
|
))
|
||||||
|
if (data.status === 'SUCCESS') {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activities'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health-summary'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore transient poll errors */ }
|
||||||
|
}, 2000)
|
||||||
|
intervalsRef.current[taskId] = intervalId
|
||||||
|
}, [queryClient])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { Object.values(intervalsRef.current).forEach(clearInterval) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const upload = useMutation({
|
const upload = useMutation({
|
||||||
mutationFn: async (file) => {
|
mutationFn: async (file) => {
|
||||||
@@ -16,7 +44,11 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
|||||||
return { file: file.name, ...data }
|
return { file: file.name, ...data }
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setTasks(t => [...t, { ...data, status: 'queued' }])
|
const task = { ...data, status: data.task_id ? 'processing' : 'queued' }
|
||||||
|
setTasks(t => [...t, task])
|
||||||
|
if (data.task_id) {
|
||||||
|
pollTask(data.task_id)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,6 +62,13 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
|||||||
multiple: true,
|
multiple: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
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 === 'failed') return <span className="ml-2 text-red-400">✗ Failed</span>
|
||||||
|
return <span className="ml-2 text-green-400">✓ Queued</span>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
@@ -73,7 +112,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
|||||||
{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>
|
||||||
)}
|
)}
|
||||||
<span className="ml-2 text-green-400">✓ Queued</span>
|
<StatusBadge status={task.status} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import { useAuthStore } from '../hooks/useAuth'
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { user: me } = useAuthStore()
|
||||||
|
|
||||||
|
const { data: users, isLoading } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: () => api.get('/users/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const setAdmin = useMutation({
|
||||||
|
mutationFn: ({ id, is_admin }) => api.patch(`/users/${id}`, { is_admin }).then(r => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||||
|
onError: e => alert(e.response?.data?.detail || 'Failed to update user'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteUser = useMutation({
|
||||||
|
mutationFn: id => api.delete(`/users/${id}`).then(r => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||||
|
onError: e => alert(e.response?.data?.detail || 'Failed to delete user'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = u => {
|
||||||
|
if (confirm(`Delete ${u.username} and ALL of their data (activities, routes, health, records)? This cannot be undone.`)) {
|
||||||
|
deleteUser.mutate(u.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-3xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Users</h1>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
New users are created in PocketID and provisioned automatically on first passkey sign-in.
|
||||||
|
Each user's data is fully separate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="p-5 text-sm text-gray-500">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-gray-500 border-b border-gray-800">
|
||||||
|
<th className="px-4 py-3 font-medium">User</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Sign-in</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Activities</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-center">Admin</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users?.map(u => {
|
||||||
|
const isMe = u.id === me?.id
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-b border-gray-800/60 last:border-0">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-white">@{u.username}{isMe && <span className="text-gray-500"> (you)</span>}</div>
|
||||||
|
{u.email && <div className="text-xs text-gray-500">{u.email}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">
|
||||||
|
{u.has_passkey ? '🔑 Passkey' : '🔒 Password'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-300">{u.activity_count}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={u.is_admin}
|
||||||
|
disabled={isMe || setAdmin.isPending}
|
||||||
|
onChange={e => setAdmin.mutate({ id: u.id, is_admin: e.target.checked })}
|
||||||
|
className="w-4 h-4 accent-blue-500 disabled:opacity-40"
|
||||||
|
title={isMe ? "You can't change your own admin status" : ''}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u)}
|
||||||
|
disabled={isMe || deleteUser.isPending}
|
||||||
|
className="text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs transition-colors"
|
||||||
|
title={isMe ? "You can't delete your own account" : ''}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user