Compare commits
96 Commits
5e2b220366
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e9cb1ea4e4 | |||
| c05d27c115 | |||
| bb09c37b3d | |||
| 491660fc6b | |||
| 8ed47d6042 | |||
| af32a0bb7f | |||
| ec87f68729 | |||
| 057eb9391a | |||
| 01a8fe135c | |||
| d350e9caea | |||
| bdd5f80c7e | |||
| 04689a29bd | |||
| 6a1726e0c3 | |||
| 0aa27713ca | |||
| bc437cce92 | |||
| e5feeb1178 | |||
| e0ddc4cbf4 | |||
| 0e18ef2291 | |||
| 0dd6eba589 | |||
| 0e4bc7b444 | |||
| bc4d68da07 | |||
| 2d94f99356 | |||
| f5d91cf8ae | |||
| 2ea691085f | |||
| bb9e8c59f4 | |||
| b6f185d5e8 | |||
| 5c5877c792 | |||
| 5256bd448c | |||
| 221b2cd333 | |||
| 45ff01f740 | |||
| 8d304545a3 | |||
| 70c7e5c0a8 | |||
| 093aa67e58 | |||
| 546fdd96b5 | |||
| 0bb1f9bc1e | |||
| 854d4ed7cb | |||
| 41a39ec3c7 | |||
| 367ae4e8f7 | |||
| e440fb35dd | |||
| 8fd7f984d9 | |||
| 13ed824f01 | |||
| 45ff4c26aa | |||
| 67fd4b3c96 | |||
| 492418586a | |||
| bf1920eb9d | |||
| 4a4cbdcc92 | |||
| 5f5551db27 | |||
| da9c1e04cb | |||
| 568dc31e97 | |||
| 02eccad578 | |||
| f0bbe92b2c | |||
| 616099402b | |||
| 37ffd4c9e0 | |||
| d57054509c | |||
| f927e32853 | |||
| a28ce0e009 | |||
| a9b3da858d | |||
| 211f77a574 | |||
| 22b41109f5 | |||
| edeb3ccece | |||
| 17ec83bfc2 | |||
| a3c039b3ea | |||
| f8c126fbda | |||
| 335bd0a053 | |||
| 7d6d34f61f | |||
| 6d224d51c5 | |||
| 0cdc653664 | |||
| c3637fa3fa | |||
| 95f704cb54 | |||
| b5fd17a597 | |||
| df6c993709 | |||
| 34284f3d9d | |||
| 16cf4a9313 | |||
| ed4ab0eff8 | |||
| 0fd3ff7414 | |||
| f609931ebc | |||
| e9811d8d83 | |||
| 93b8f00f94 | |||
| 24f8417982 | |||
| 070267eee5 | |||
| 4b93cbf5e0 | |||
| b0248fb581 | |||
| ec5a01d12a | |||
| 043b3b7269 | |||
| 38632cfe4f | |||
| 8104ca5ed0 | |||
| c4e5eb91ed | |||
| 29c39c3bbb | |||
| bfb3daba05 | |||
| 5a57e84e80 | |||
| 264c27469b | |||
| ecc077f153 | |||
| 9fd12676d1 | |||
| 8fdf3df013 | |||
| e0b09d6d44 | |||
| 97e79fd020 |
+44
-31
@@ -3,25 +3,44 @@ name: Build and push images
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: # allow manual trigger from Gitea UI
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ vars.GITEA_URL }} # e.g. gitea.yourdomain.com — set in repo Settings → Variables
|
||||
OWNER: ${{ gitea.repository_owner }}
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-backend:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
- name: Validate package.json
|
||||
run: |
|
||||
# Fail if package.json is invalid JSON
|
||||
python3 -c "import json, sys; json.load(open('frontend/package.json'))" || \
|
||||
{ echo "ERROR: frontend/package.json is invalid JSON"; exit 1; }
|
||||
|
||||
# Fail if non-existent packages are present
|
||||
if grep -q "@polyline-codec" frontend/package.json; then
|
||||
echo "ERROR: @polyline-codec/core does not exist on npm - remove it"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail if npm ci is still in Dockerfile (requires lockfile we don't have)
|
||||
if grep -q "npm ci" frontend/Dockerfile; then
|
||||
echo "ERROR: frontend/Dockerfile uses 'npm ci' but no package-lock.json exists - change to 'npm install'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Validation passed"
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to registry
|
||||
run: echo "${{ secrets.PACKAGE_TOKEN }}" | docker login gitea.jarrett.eu -u ${{ gitea.actor }} --password-stdin
|
||||
|
||||
- name: Build and push backend
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -30,21 +49,18 @@ jobs:
|
||||
file: ./backend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:latest
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:${{ gitea.sha }}
|
||||
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-backend:latest
|
||||
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-backend:${{ gitea.sha }}
|
||||
|
||||
build-worker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
- name: Log in to registry
|
||||
run: echo "${{ secrets.PACKAGE_TOKEN }}" | docker login gitea.jarrett.eu -u ${{ gitea.actor }} --password-stdin
|
||||
|
||||
- name: Build and push worker
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -53,21 +69,18 @@ jobs:
|
||||
file: ./backend/Dockerfile.worker
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:latest
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:${{ gitea.sha }}
|
||||
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-worker:latest
|
||||
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-worker:${{ gitea.sha }}
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
- name: Log in to registry
|
||||
run: echo "${{ secrets.PACKAGE_TOKEN }}" | docker login gitea.jarrett.eu -u ${{ gitea.actor }} --password-stdin
|
||||
|
||||
- name: Build and push frontend
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -76,8 +89,8 @@ jobs:
|
||||
file: ./frontend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:latest
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:${{ gitea.sha }}
|
||||
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-frontend:latest
|
||||
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-frontend:${{ gitea.sha }}
|
||||
build-args: |
|
||||
VITE_API_URL=/api
|
||||
VITE_MAPBOX_TOKEN=
|
||||
VITE_MAPBOX_TOKEN=
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Node / frontend build artifacts
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# OS noise
|
||||
.DS_Store
|
||||
Binary file not shown.
@@ -0,0 +1,155 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this project is
|
||||
|
||||
MileVault is a self-hosted fitness tracker. It ingests Garmin FIT files and Strava exports, stores activity and wellness data in TimescaleDB (PostgreSQL), and serves a React dashboard with maps, charts, personal records, and health trends.
|
||||
|
||||
## Running locally
|
||||
|
||||
Everything runs in Docker Compose. There is no way to run individual services without Docker unless you wire up your own Postgres + Redis.
|
||||
|
||||
```bash
|
||||
# First-time setup (generates .env with secrets, then starts containers):
|
||||
./scripts/manage.sh setup
|
||||
|
||||
# Start/stop:
|
||||
./scripts/manage.sh start
|
||||
./scripts/manage.sh stop
|
||||
|
||||
# Follow logs (all services, or a specific one):
|
||||
./scripts/manage.sh logs
|
||||
./scripts/manage.sh logs backend
|
||||
|
||||
# Backup/restore the database:
|
||||
./scripts/manage.sh backup
|
||||
./scripts/manage.sh restore milevault_backup_20240101_120000.sql
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
`docker-compose.yml` — build from source (dev/CI).
|
||||
`docker-compose.deploy.yml` — pull pre-built images from the Gitea registry (production).
|
||||
|
||||
The Gitea Actions workflow (`.gitea/workflows/build.yml`) auto-builds and pushes images on push to `main`. Deployment machines only need `docker-compose.deploy.yml` and `nginx.conf`.
|
||||
|
||||
`./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
|
||||
# Rebuild and restart from source:
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Update a deployed instance:
|
||||
docker compose -f docker-compose.deploy.yml pull
|
||||
docker compose -f docker-compose.deploy.yml up -d
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Services
|
||||
|
||||
| Service | Purpose |
|
||||
|---------|---------|
|
||||
| `db` | TimescaleDB (PostgreSQL 16) — `activity_data_points` is a hypertable |
|
||||
| `redis` | Celery broker + result backend |
|
||||
| `backend` | FastAPI (async) — uvicorn, single worker |
|
||||
| `worker` | Celery worker — synchronous SQLAlchemy (asyncio incompatible with prefork) |
|
||||
| `beat` | Celery Beat scheduler — runs `sync_all_garmin_connect` every 30 minutes |
|
||||
| `frontend` | React SPA built by Vite at container build time |
|
||||
| `nginx` | Reverse proxy, serves the SPA |
|
||||
|
||||
### Backend (`backend/app/`)
|
||||
|
||||
- `main.py` — FastAPI app, DB init on startup (creates tables, seeds admin user, creates TimescaleDB hypertable)
|
||||
- `core/` — `config.py` (pydantic-settings from env), `database.py` (async engine for FastAPI + sync engine for Celery), `security.py` (JWT, bcrypt)
|
||||
- `api/` — routers: `auth`, `activities`, `routes`, `health`, `records`, `upload`, `profile`, `garmin_sync`, `users`, `segments`
|
||||
- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `Segment`, `SegmentEffort`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig` (the old `RouteSegment` model was removed in the segments rewrite; a new GPS-geometry `Segment`/`SegmentEffort` pair replaces it)
|
||||
- `services/fit_parser.py` — parses Garmin FIT and GPX files; handles raw FIT timestamps (FIT epoch offset 631065600s) and semicircle→degree conversion
|
||||
- `services/wellness_parser.py` — parses Garmin wellness FIT files (metrics, sleep, HRV, SPO2, etc.)
|
||||
- `services/route_matcher.py` — bounding-box pre-filter + DTW (Dynamic Time Warping) for GPS track similarity
|
||||
- `services/garmin_connect_sync.py` — Garmin Connect API integration; `authenticate_garmin()` tries stored OAuth token first, falls back to email/password; Garmin credentials stored Fernet-encrypted using `SECRET_KEY` as the key
|
||||
- `workers/tasks.py` — Celery tasks: `process_activity_file`, `parse_wellness_fit`, `detect_route`, `compute_personal_records`, `match_segment`, `match_activity_segments`, `process_garmin_health_zip`, `sync_garmin_connect_user`, `sync_all_garmin_connect` (beat-scheduled), `recalculate_hr_zones_for_user`
|
||||
|
||||
### Key design decisions
|
||||
|
||||
**Async vs sync split**: FastAPI uses async SQLAlchemy (`asyncpg`). Celery workers use sync SQLAlchemy (`psycopg2`) because Celery's prefork model doesn't survive asyncio engine forks. The `DATABASE_URL` uses `postgresql+asyncpg://`; the worker converts it to `postgresql+psycopg2://` at runtime.
|
||||
|
||||
**File routing in Celery**: `process_activity_file` inspects the filename; files matching wellness suffixes (`_METRICS.fit`, `_WELLNESS.fit`, `_SLEEP.fit`, etc.) are routed to `parse_wellness_fit` instead.
|
||||
|
||||
**Schema management**: No Alembic migrations are used in production. `Base.metadata.create_all` runs at startup with retry logic to handle multi-worker races. Post-initial schema changes (new columns, constraint changes) are applied as `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` / `DROP CONSTRAINT IF EXISTS` statements in `init_db()` in `main.py` — this is the only place schema migrations happen. Health metrics upserts use raw SQL `ON CONFLICT ... DO UPDATE SET ... COALESCE(EXCLUDED.x, existing.x)` to merge data from multiple file sources without overwriting.
|
||||
|
||||
**PocketID OIDC**: Optional passkey auth. Config is read from the admin user's DB record first, falling back to env vars. The OAuth callback redirects to `/?token=<jwt>` and `useAuth.js` extracts the token from the URL at module load time.
|
||||
|
||||
### Frontend (`frontend/src/`)
|
||||
|
||||
- `App.jsx` — React Router v6, `RequireAuth` wrapper, all routes defined here
|
||||
- `hooks/useAuth.js` — Zustand store for auth state, reads JWT from `localStorage`, handles PocketID token-in-URL flow
|
||||
- `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.
|
||||
- `pages/` — one file per route: `Dashboard`, `Activities`, `ActivityDetail`, `Routes`, `Records`, `Health`, `Upload`, `Profile`, `Users`, `Login`
|
||||
- `components/activity/` — `ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route)
|
||||
- `components/ui/` — `Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards)
|
||||
|
||||
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.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Required in `.env` (or passed to Docker Compose):
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `DATABASE_URL` | Full async DB URL (`postgresql+asyncpg://...`) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |
|
||||
|
||||
## milevault_export/
|
||||
|
||||
`milevault_export/` is a sanitised snapshot of the project used for public distribution (stripped of dev-only configs). It mirrors the main project structure. When making changes that affect deployment files (`docker-compose.yml`, `nginx.conf`, `scripts/manage.sh`, `docker/init.sql`, etc.), keep this directory in sync manually.
|
||||
|
||||
## Rules
|
||||
- The current build will always be running in docker at ~/milevault_docker with the following container names:
|
||||
`milevault_backend`
|
||||
`milevault_db`
|
||||
`milevault_frontend`
|
||||
`milevault_redis`
|
||||
`milevault_worker`
|
||||
- When an issue is highlighted by the user, check the logs on these containers for the error, do not spin up new containers, use these for finding the problem, rectify the issues in ~/milevault project without running the updated versions, push to git instead.
|
||||
- Do NOT patch the running files under any circumstances, fix the development files.
|
||||
+3
-2
@@ -11,5 +11,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Tables are created at runtime by SQLAlchemy in app/main.py lifespan
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
# Single worker avoids race condition during DB initialization.
|
||||
# For a personal app this is fine; async handles concurrent requests well.
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy import select, func, desc, delete
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
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, Activity, ActivityDataPoint, ActivityLap
|
||||
from app.models.user import User, Activity, ActivityDataPoint, ActivityLap, PersonalRecord
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -35,6 +35,7 @@ class ActivitySummary(BaseModel):
|
||||
|
||||
class ActivityDetail(ActivitySummary):
|
||||
end_time: Optional[datetime]
|
||||
moving_time_s: Optional[float]
|
||||
elevation_loss_m: Optional[float]
|
||||
max_heart_rate: Optional[float]
|
||||
avg_power: Optional[float]
|
||||
@@ -75,6 +76,30 @@ class LapOut(BaseModel):
|
||||
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])
|
||||
async def list_activities(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -126,7 +151,6 @@ async def get_data_points(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Verify ownership
|
||||
act = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.id == activity_id,
|
||||
@@ -172,6 +196,103 @@ async def get_laps(
|
||||
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")
|
||||
async def rename_activity(
|
||||
activity_id: int,
|
||||
@@ -209,5 +330,46 @@ async def delete_activity(
|
||||
activity = result.scalar_one_or_none()
|
||||
if not activity:
|
||||
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.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))
|
||||
# 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.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"}
|
||||
+237
-52
@@ -4,15 +4,115 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import timedelta
|
||||
from jose import jwt, JWTError
|
||||
import httpx
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, create_access_token, hash_password, get_current_user
|
||||
from app.core.security import verify_password, create_access_token, get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
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):
|
||||
"""Get PocketID config from DB (admin user) falling back to env vars."""
|
||||
admin = await _config_admin(db)
|
||||
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
|
||||
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
|
||||
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):
|
||||
access_token: str
|
||||
@@ -27,6 +127,7 @@ class UserOut(BaseModel):
|
||||
username: str
|
||||
email: Optional[str]
|
||||
is_admin: bool
|
||||
has_passkey: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -37,98 +138,182 @@ async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
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()
|
||||
|
||||
if not user or not user.hashed_password:
|
||||
if not user:
|
||||
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")
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return Token(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
return Token(access_token=token, token_type="bearer",
|
||||
user_id=user.id, username=user.username, is_admin=user.is_admin)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
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")
|
||||
async def pocketid_available():
|
||||
return {"available": bool(settings.pocketid_issuer and settings.pocketid_client_id)}
|
||||
async def pocketid_available(db: AsyncSession = Depends(get_db)):
|
||||
issuer, client_id, _ = await _get_pocketid_config(db)
|
||||
return {"available": bool(issuer and client_id)}
|
||||
|
||||
|
||||
@router.get("/pocketid/login-url")
|
||||
async def pocketid_login_url():
|
||||
"""Return the OIDC authorization URL for PocketID."""
|
||||
if not settings.pocketid_issuer:
|
||||
async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
||||
issuer, client_id, _ = await _get_pocketid_config(db)
|
||||
if not issuer or not client_id:
|
||||
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||
|
||||
params = {
|
||||
"client_id": settings.pocketid_client_id,
|
||||
"redirect_uri": "/api/auth/pocketid/callback",
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email",
|
||||
}
|
||||
from urllib.parse import urlencode
|
||||
url = f"{settings.pocketid_issuer}/authorize?{urlencode(params)}"
|
||||
return {"url": url}
|
||||
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_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)}"}
|
||||
|
||||
|
||||
@router.get("/pocketid/callback")
|
||||
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Exchange OIDC code for tokens and create/login user."""
|
||||
if not settings.pocketid_issuer:
|
||||
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)
|
||||
if not issuer:
|
||||
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||
|
||||
# Exchange code for tokens
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{settings.pocketid_issuer}/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": "/api/auth/pocketid/callback",
|
||||
"client_id": settings.pocketid_client_id,
|
||||
"client_secret": settings.pocketid_client_secret,
|
||||
},
|
||||
f"{issuer}/api/oidc/token",
|
||||
data={"grant_type": "authorization_code", "code": code,
|
||||
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||
"client_id": client_id, "client_secret": client_secret},
|
||||
)
|
||||
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")
|
||||
|
||||
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(
|
||||
f"{settings.pocketid_issuer}/userinfo",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
f"{issuer}/api/oidc/userinfo",
|
||||
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()
|
||||
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
sub = userinfo.get("sub")
|
||||
email = userinfo.get("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))
|
||||
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:
|
||||
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)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
# Redirect to frontend with token
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=f"/?token={token}")
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
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.core.config import settings
|
||||
from app.models.user import User, GarminConnectConfig
|
||||
|
||||
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):
|
||||
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
|
||||
sync_interval_minutes: int # how often the automatic sync runs
|
||||
last_sync_at: Optional[datetime]
|
||||
last_sync_status: Optional[str]
|
||||
connected: bool
|
||||
|
||||
class Config:
|
||||
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)
|
||||
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,
|
||||
sync_interval_minutes=settings.garmin_sync_interval_minutes,
|
||||
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,
|
||||
sync_interval_minutes=settings.garmin_sync_interval_minutes,
|
||||
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")
|
||||
|
||||
# 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_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,
|
||||
sync_interval_minutes=settings.garmin_sync_interval_minutes,
|
||||
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)
|
||||
|
||||
# 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"}
|
||||
|
||||
|
||||
@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"}
|
||||
+52
-21
@@ -1,9 +1,9 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from pydantic import BaseModel, model_validator
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
@@ -44,6 +44,13 @@ class HealthMetricOut(BaseModel):
|
||||
active_calories: Optional[float]
|
||||
total_calories: 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:
|
||||
from_attributes = True
|
||||
@@ -53,17 +60,20 @@ class HealthMetricOut(BaseModel):
|
||||
async def list_health_metrics(
|
||||
from_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),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
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)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -73,8 +83,6 @@ async def health_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Latest values + 30-day averages for dashboard widgets."""
|
||||
# Latest record
|
||||
latest_result = await db.execute(
|
||||
select(HealthMetric)
|
||||
.where(HealthMetric.user_id == current_user.id)
|
||||
@@ -83,9 +91,7 @@ async def health_summary(
|
||||
)
|
||||
latest = latest_result.scalar_one_or_none()
|
||||
|
||||
# 30-day averages
|
||||
from datetime import timedelta, timezone
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
|
||||
avg_result = await db.execute(
|
||||
select(
|
||||
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"),
|
||||
).where(
|
||||
HealthMetric.user_id == current_user.id,
|
||||
HealthMetric.date >= cutoff,
|
||||
func.date(HealthMetric.date) >= cutoff,
|
||||
)
|
||||
)
|
||||
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")
|
||||
async def add_manual_metric(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Manually add or update a health metric for a given date."""
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from fastapi import HTTPException
|
||||
date_str = body.get("date")
|
||||
if not date_str:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="date required")
|
||||
|
||||
metric_date = datetime.fromisoformat(date_str)
|
||||
|
||||
# Check for existing
|
||||
existing = await db.execute(
|
||||
select(HealthMetric).where(
|
||||
HealthMetric.user_id == current_user.id,
|
||||
@@ -153,4 +184,4 @@ async def add_manual_metric(
|
||||
db.add(metric)
|
||||
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,262 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user, hash_password, verify_password
|
||||
from app.models.user import User, WeightLog
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Profile ────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
max_heart_rate: Optional[int] = None
|
||||
resting_heart_rate: Optional[int] = None
|
||||
birth_year: Optional[int] = None
|
||||
height_cm: Optional[float] = None
|
||||
biological_sex: Optional[str] = None
|
||||
goal_weight_kg: Optional[float] = None
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str]
|
||||
max_heart_rate: Optional[int]
|
||||
resting_heart_rate: Optional[int]
|
||||
birth_year: Optional[int]
|
||||
height_cm: Optional[float]
|
||||
biological_sex: Optional[str]
|
||||
goal_weight_kg: Optional[float]
|
||||
estimated_max_hr: Optional[int]
|
||||
is_admin: bool
|
||||
dashboard_layout: Optional[list] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DashboardLayoutIn(BaseModel):
|
||||
layout: Optional[list] = None # react-grid-layout array of {i,x,y,w,h}
|
||||
|
||||
|
||||
def _estimated_max_hr(user: User) -> Optional[int]:
|
||||
if user.birth_year:
|
||||
return 220 - (datetime.now().year - user.birth_year)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=ProfileOut)
|
||||
async def get_profile(current_user: User = Depends(get_current_user)):
|
||||
return {**{c.name: getattr(current_user, c.name)
|
||||
for c in User.__table__.columns},
|
||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||||
|
||||
|
||||
@router.put("/dashboard-layout")
|
||||
async def save_dashboard_layout(
|
||||
body: DashboardLayoutIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Persist the user's customised dashboard widget layout."""
|
||||
current_user.dashboard_layout = body.layout
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.patch("/", response_model=ProfileOut)
|
||||
async def update_profile(
|
||||
body: ProfileUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
old_max_hr = current_user.max_heart_rate
|
||||
if body.max_heart_rate is not None:
|
||||
if not (100 <= body.max_heart_rate <= 250):
|
||||
raise HTTPException(400, "Max HR must be 100–250")
|
||||
current_user.max_heart_rate = body.max_heart_rate
|
||||
if body.resting_heart_rate is not None:
|
||||
if not (20 <= body.resting_heart_rate <= 120):
|
||||
raise HTTPException(400, "Resting HR must be 20–120")
|
||||
current_user.resting_heart_rate = body.resting_heart_rate
|
||||
if body.birth_year is not None:
|
||||
if not (1920 <= body.birth_year <= 2010):
|
||||
raise HTTPException(400, "Invalid birth year")
|
||||
current_user.birth_year = body.birth_year
|
||||
if body.height_cm is not None:
|
||||
if not (50 <= body.height_cm <= 300):
|
||||
raise HTTPException(400, "Height must be 50–300 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
|
||||
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.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)
|
||||
for c in User.__table__.columns},
|
||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||||
|
||||
|
||||
# ── Password change ────────────────────────────────────────────────────────
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: PasswordChange,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not current_user.hashed_password:
|
||||
raise HTTPException(400, "Account uses passkey login — no password to change")
|
||||
if not verify_password(body.current_password, current_user.hashed_password):
|
||||
raise HTTPException(400, "Current password is incorrect")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(400, "New password must be at least 8 characters")
|
||||
current_user.hashed_password = hash_password(body.new_password)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── PocketID configuration (admin only) ────────────────────────────────────
|
||||
|
||||
class PocketIDConfig(BaseModel):
|
||||
issuer: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
client_secret: Optional[str] = None
|
||||
allowed_group: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/pocketid-config")
|
||||
async def get_pocketid_config(current_user: User = Depends(get_current_user)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
from app.core.config import settings
|
||||
# Show DB config if set, fall back to env
|
||||
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
|
||||
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 {
|
||||
"issuer": issuer or "",
|
||||
"client_id": client_id or "",
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pocketid-config")
|
||||
async def save_pocketid_config(
|
||||
body: PocketIDConfig,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
if body.issuer is not None:
|
||||
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
|
||||
if body.client_id is not None:
|
||||
current_user.pocketid_client_id = body.client_id or None
|
||||
# Only overwrite the secret when a non-empty value is supplied; a blank
|
||||
# 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()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Weight log ─────────────────────────────────────────────────────────────
|
||||
|
||||
class WeightEntry(BaseModel):
|
||||
date: datetime
|
||||
weight_kg: float
|
||||
body_fat_pct: Optional[float] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class WeightOut(BaseModel):
|
||||
id: int
|
||||
date: datetime
|
||||
weight_kg: float
|
||||
body_fat_pct: Optional[float]
|
||||
note: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/weight", response_model=List[WeightOut])
|
||||
async def list_weight(
|
||||
limit: int = Query(365, ge=1, le=2000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(WeightLog)
|
||||
.where(WeightLog.user_id == current_user.id)
|
||||
.order_by(desc(WeightLog.date))
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/weight", response_model=WeightOut)
|
||||
async def log_weight(
|
||||
body: WeightEntry,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not (20 <= body.weight_kg <= 500):
|
||||
raise HTTPException(400, "Weight must be 20–500 kg")
|
||||
entry = WeightLog(
|
||||
user_id=current_user.id,
|
||||
date=body.date,
|
||||
weight_kg=body.weight_kg,
|
||||
body_fat_pct=body.body_fat_pct,
|
||||
note=body.note,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete("/weight/{entry_id}", status_code=204)
|
||||
async def delete_weight(
|
||||
entry_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(WeightLog).where(
|
||||
WeightLog.id == entry_id,
|
||||
WeightLog.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(404, "Not found")
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
@@ -7,7 +7,7 @@ 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, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity
|
||||
from app.models.user import User, PersonalRecord, NamedRoute, HealthMetric, Activity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -44,6 +44,36 @@ async def list_records(
|
||||
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}")
|
||||
async def record_history(
|
||||
distance_label: str,
|
||||
|
||||
+140
-64
@@ -1,29 +1,27 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy import select, desc, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
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()
|
||||
|
||||
|
||||
class SegmentCreate(BaseModel):
|
||||
name: str
|
||||
start_distance_m: float
|
||||
end_distance_m: float
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class RouteCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
sport_type: Optional[str] = None
|
||||
activity_id: int # use this activity as the reference route
|
||||
activity_id: int
|
||||
|
||||
|
||||
class RouteUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
sport_type: Optional[str] = None
|
||||
|
||||
|
||||
class RouteOut(BaseModel):
|
||||
@@ -34,18 +32,9 @@ class RouteOut(BaseModel):
|
||||
reference_polyline: Optional[str]
|
||||
bounding_box: Optional[dict]
|
||||
distance_m: Optional[float]
|
||||
auto_detected: Optional[bool]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SegmentOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
start_distance_m: float
|
||||
end_distance_m: float
|
||||
description: Optional[str]
|
||||
activity_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -56,12 +45,58 @@ async def list_routes(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Fetch routes with activity counts in one query
|
||||
count_subq = (
|
||||
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
|
||||
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
|
||||
.group_by(Activity.named_route_id)
|
||||
.subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(NamedRoute)
|
||||
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)
|
||||
.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")
|
||||
async def recent_activities_for_route(
|
||||
days: int = Query(14, ge=1, le=90),
|
||||
sport_type: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return recent activities for the route creation dropdown."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
q = select(Activity).where(
|
||||
Activity.user_id == current_user.id,
|
||||
Activity.start_time >= cutoff,
|
||||
Activity.sport_type != "swimming",
|
||||
)
|
||||
if sport_type:
|
||||
q = q.where(Activity.sport_type == sport_type)
|
||||
q = q.order_by(desc(Activity.start_time)).limit(50)
|
||||
result = await db.execute(q)
|
||||
activities = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
"sport_type": a.sport_type,
|
||||
"start_time": a.start_time,
|
||||
"distance_m": a.distance_m,
|
||||
"duration_s": a.duration_s,
|
||||
}
|
||||
for a in activities
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=RouteOut)
|
||||
@@ -70,7 +105,6 @@ async def create_route(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Load the reference activity
|
||||
act_result = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.id == body.activity_id,
|
||||
@@ -89,11 +123,10 @@ async def create_route(
|
||||
reference_polyline=activity.polyline,
|
||||
bounding_box=activity.bounding_box,
|
||||
distance_m=activity.distance_m,
|
||||
auto_detected=False,
|
||||
)
|
||||
db.add(route)
|
||||
await db.flush()
|
||||
|
||||
# Link this activity to the route
|
||||
activity.named_route_id = route.id
|
||||
await db.commit()
|
||||
await db.refresh(route)
|
||||
@@ -118,13 +151,37 @@ async def get_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")
|
||||
async def route_activities(
|
||||
route_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""All activities on this named route, ordered fastest first."""
|
||||
result = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.named_route_id == route_id,
|
||||
@@ -146,6 +203,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")
|
||||
async def assign_activity_to_route(
|
||||
route_id: int,
|
||||
@@ -153,7 +265,6 @@ async def assign_activity_to_route(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Manually assign an activity to a named route."""
|
||||
activity_id = body.get("activity_id")
|
||||
act_result = await db.execute(
|
||||
select(Activity).where(
|
||||
@@ -164,41 +275,6 @@ async def assign_activity_to_route(
|
||||
activity = act_result.scalar_one_or_none()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
|
||||
activity.named_route_id = route_id
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
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),
|
||||
):
|
||||
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,
|
||||
)
|
||||
db.add(segment)
|
||||
await db.commit()
|
||||
await db.refresh(segment)
|
||||
return segment
|
||||
|
||||
@@ -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()
|
||||
+109
-26
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
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()
|
||||
|
||||
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB upload cap
|
||||
MAX_EXTRACT_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB total uncompressed cap (zip-bomb guard)
|
||||
_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
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:
|
||||
"""Stream an upload to disk under dest_dir, enforcing the size cap."""
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
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")
|
||||
async def upload_activity(
|
||||
file: UploadFile = File(...),
|
||||
@@ -62,19 +111,44 @@ async def upload_garmin_export(
|
||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
||||
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.mkdir(exist_ok=True)
|
||||
|
||||
task_ids = []
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
for name in zf.namelist():
|
||||
lower = name.lower()
|
||||
if lower.endswith(".fit"):
|
||||
fit_path = extract_dir / name
|
||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
try:
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
extracted = _safe_extract(zf, extract_dir)
|
||||
except zipfile.BadZipFile:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||
|
||||
has_health = False
|
||||
for path in extracted:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".fit":
|
||||
task = process_activity_file.delay(str(path), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
elif suffix == ".json":
|
||||
has_health = True # Garmin wellness data is exported as JSON files
|
||||
elif suffix == ".zip":
|
||||
# Garmin exports nest activity FIT files inside sub-zips
|
||||
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
|
||||
nested_extract = path.parent / path.stem
|
||||
try:
|
||||
with zipfile.ZipFile(path) as nzf:
|
||||
nested = _safe_extract(nzf, nested_extract)
|
||||
except zipfile.BadZipFile:
|
||||
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
|
||||
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
||||
@@ -82,7 +156,7 @@ async def upload_garmin_export(
|
||||
return {
|
||||
"status": "queued",
|
||||
"activity_tasks": len(task_ids),
|
||||
"health_task": health_task.id,
|
||||
"task_id": health_task.id,
|
||||
}
|
||||
|
||||
|
||||
@@ -100,22 +174,31 @@ async def upload_strava_export(
|
||||
dest = save_upload(file, dest_dir)
|
||||
|
||||
extract_dir = dest_dir / f"strava_{dest.stem}"
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
task_ids = []
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
for name in zf.namelist():
|
||||
lower = name.lower()
|
||||
if lower.endswith(".fit") or lower.endswith(".gpx"):
|
||||
file_path = extract_dir / name
|
||||
ext = Path(name).suffix[1:]
|
||||
task = process_activity_file.delay(str(file_path), current_user.id, ext)
|
||||
task_ids.append(task.id)
|
||||
try:
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
extracted = _safe_extract(zf, extract_dir)
|
||||
except zipfile.BadZipFile:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||
|
||||
for path in extracted:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in (".fit", ".gpx"):
|
||||
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
|
||||
task_ids.append(task.id)
|
||||
|
||||
if not task_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No activity files (.fit or .gpx) found in this Strava archive",
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
"activity_tasks": len(task_ids),
|
||||
"task_id": task_ids[-1] if task_ids else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -6,27 +6,26 @@ from typing import Optional
|
||||
class Settings(BaseSettings):
|
||||
# Database
|
||||
database_url: str = Field(..., env="DATABASE_URL")
|
||||
|
||||
# Redis
|
||||
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
|
||||
|
||||
# Auth
|
||||
secret_key: str = Field(..., env="SECRET_KEY")
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
# Admin account
|
||||
admin_username: str = Field("admin", env="ADMIN_USERNAME")
|
||||
admin_password: str = Field(..., 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_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
|
||||
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
|
||||
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
|
||||
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
||||
|
||||
# Environment
|
||||
environment: str = Field("production", env="ENVIRONMENT")
|
||||
|
||||
@@ -35,4 +34,4 @@ class Settings(BaseSettings):
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
settings = Settings()
|
||||
@@ -1,7 +1,9 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
# Async engine for FastAPI
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.environment == "development",
|
||||
@@ -15,6 +17,19 @@ AsyncSessionLocal = async_sessionmaker(
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
# Sync engine for Celery workers (Celery + asyncio don't mix well)
|
||||
# Convert async URL to sync: postgresql+asyncpg:// → postgresql+psycopg2://
|
||||
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
||||
sync_engine = create_engine(
|
||||
sync_url,
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
SyncSessionLocal = sessionmaker(sync_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
+201
-24
@@ -2,45 +2,218 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy import text
|
||||
import asyncio
|
||||
|
||||
from app.core.database import engine, AsyncSessionLocal, Base
|
||||
from app.core.config import settings
|
||||
from app.api import auth, activities, routes, health, records, upload
|
||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users, segments
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async def init_db():
|
||||
"""Create tables then seed admin, with retries for slow DB startup.
|
||||
|
||||
# Try to enable TimescaleDB hypertable for data points
|
||||
Multiple uvicorn workers may race here on first start. We tolerate
|
||||
duplicate table errors since they just mean another worker got there first.
|
||||
"""
|
||||
for attempt in range(15):
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
break
|
||||
except Exception as e:
|
||||
msg = str(e).lower()
|
||||
if "already exists" in msg or "duplicate" in msg or "pg_type_typname" in msg:
|
||||
print("Tables already created by another worker - skipping")
|
||||
break
|
||||
if attempt == 14:
|
||||
raise
|
||||
print(f"DB not ready yet (attempt {attempt + 1}/15): {e}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Try TimescaleDB hypertable (non-fatal)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"SELECT create_hypertable('activity_data_points', 'timestamp', "
|
||||
"if_not_exists => TRUE, migrate_data => TRUE)"
|
||||
))
|
||||
except Exception:
|
||||
pass # Already exists or TimescaleDB not available
|
||||
except Exception as e:
|
||||
print(f"TimescaleDB hypertable skipped: {e}")
|
||||
|
||||
# Seed admin user
|
||||
async with AsyncSessionLocal() as db:
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.core.security import hash_password
|
||||
# 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}")
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == settings.admin_username)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
admin = User(
|
||||
username=settings.admin_username,
|
||||
hashed_password=hash_password(settings.admin_password),
|
||||
is_admin=True,
|
||||
# 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
|
||||
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}")
|
||||
|
||||
# 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}")
|
||||
|
||||
# dashboard_layout 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 dashboard_layout JSON"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"users.dashboard_layout 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)
|
||||
if not settings.admin_password:
|
||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||
return
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.core.security import hash_password
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == settings.admin_username)
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
if not result.scalar_one_or_none():
|
||||
admin = User(
|
||||
username=settings.admin_username,
|
||||
hashed_password=hash_password(settings.admin_password),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
print(f"Admin user '{settings.admin_username}' created")
|
||||
except Exception as e:
|
||||
msg = str(e).lower()
|
||||
if "duplicate" in msg or "unique" in msg:
|
||||
print("Admin user already exists - skipping seed")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
@@ -64,6 +237,10 @@ app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
|
||||
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
|
||||
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
app.include_router(segments.router, prefix="/api/segments", tags=["segments"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
+119
-63
@@ -1,6 +1,6 @@
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Float, DateTime, Boolean,
|
||||
ForeignKey, Text, JSON, Index, UniqueConstraint
|
||||
ForeignKey, Text, JSON, Index, UniqueConstraint, text
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
@@ -17,14 +17,72 @@ class User(Base):
|
||||
id = Column(Integer, primary_key=True)
|
||||
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||
email = Column(String(256), unique=True, nullable=True)
|
||||
hashed_password = Column(String(256), nullable=True) # null = OIDC-only user
|
||||
hashed_password = Column(String(256), nullable=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
pocketid_sub = Column(String(256), unique=True, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
# Health profile
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
resting_heart_rate = Column(Integer, nullable=True)
|
||||
birth_year = Column(Integer, nullable=True)
|
||||
height_cm = Column(Float, nullable=True)
|
||||
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_issuer = Column(String(512), nullable=True)
|
||||
pocketid_client_id = 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)
|
||||
|
||||
# Saved dashboard widget layout (react-grid-layout array). Null = use default.
|
||||
dashboard_layout = Column(JSON, nullable=True)
|
||||
|
||||
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
||||
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
|
||||
|
||||
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):
|
||||
"""Manual weight entries separate from health_metrics for easy tracking."""
|
||||
__tablename__ = "weight_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
date = Column(DateTime(timezone=True), nullable=False)
|
||||
weight_kg = Column(Float, nullable=False)
|
||||
body_fat_pct = Column(Float, nullable=True)
|
||||
note = Column(String(256), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_weight_user_date", "user_id", "date"),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="weight_logs")
|
||||
|
||||
|
||||
class Activity(Base):
|
||||
@@ -32,16 +90,13 @@ class Activity(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# Core fields
|
||||
name = Column(String(256), nullable=False)
|
||||
sport_type = Column(String(64), nullable=False) # running, cycling, swimming, etc.
|
||||
sport_type = Column(String(64), nullable=False)
|
||||
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Metrics summary (cached aggregates)
|
||||
distance_m = Column(Float, nullable=True) # metres
|
||||
duration_s = Column(Float, nullable=True) # seconds
|
||||
distance_m = 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_loss_m = Column(Float, nullable=True)
|
||||
avg_heart_rate = Column(Float, nullable=True)
|
||||
@@ -55,23 +110,14 @@ class Activity(Base):
|
||||
calories = Column(Float, nullable=True)
|
||||
training_stress_score = Column(Float, nullable=True)
|
||||
vo2max_estimate = Column(Float, nullable=True)
|
||||
|
||||
# Route reference
|
||||
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
|
||||
|
||||
# Raw GPS track (encoded polyline for quick map render)
|
||||
named_route_id = Column(Integer, ForeignKey("named_routes.id", ondelete="SET NULL"), nullable=True)
|
||||
polyline = Column(Text, nullable=True)
|
||||
bounding_box = Column(JSON, nullable=True) # {min_lat, max_lat, min_lon, max_lon}
|
||||
|
||||
# Source file info
|
||||
bounding_box = Column(JSON, nullable=True)
|
||||
source_file = Column(String(512), nullable=True)
|
||||
source_type = Column(String(32), nullable=True) # fit, gpx, strava_json
|
||||
source_type = Column(String(32), nullable=True)
|
||||
garmin_activity_id = Column(String(64), nullable=True, unique=True)
|
||||
strava_activity_id = Column(String(64), nullable=True, unique=True)
|
||||
|
||||
# HR zones (% of time in each zone)
|
||||
hr_zones = Column(JSON, nullable=True) # {z1: pct, z2: pct, ...}
|
||||
|
||||
hr_zones = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
user = relationship("User", back_populates="activities")
|
||||
@@ -81,16 +127,10 @@ class Activity(Base):
|
||||
|
||||
|
||||
class ActivityDataPoint(Base):
|
||||
"""
|
||||
TimescaleDB hypertable - one row per second of activity data.
|
||||
After creation, converted to hypertable in migration:
|
||||
SELECT create_hypertable('activity_data_points', 'timestamp');
|
||||
"""
|
||||
__tablename__ = "activity_data_points"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
|
||||
timestamp = Column(DateTime(timezone=True), nullable=False)
|
||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
|
||||
timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
altitude_m = Column(Float, nullable=True)
|
||||
@@ -99,11 +139,7 @@ class ActivityDataPoint(Base):
|
||||
speed_ms = Column(Float, nullable=True)
|
||||
power = Column(Float, nullable=True)
|
||||
temperature_c = Column(Float, nullable=True)
|
||||
distance_m = Column(Float, nullable=True) # cumulative distance
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_adp_activity_time", "activity_id", "timestamp"),
|
||||
)
|
||||
distance_m = Column(Float, nullable=True)
|
||||
|
||||
activity = relationship("Activity", back_populates="data_points")
|
||||
|
||||
@@ -133,28 +169,53 @@ class NamedRoute(Base):
|
||||
name = Column(String(256), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
sport_type = Column(String(64), nullable=True)
|
||||
reference_polyline = Column(Text, nullable=True) # canonical route polyline
|
||||
reference_polyline = Column(Text, nullable=True)
|
||||
bounding_box = Column(JSON, nullable=True)
|
||||
distance_m = Column(Float, nullable=True)
|
||||
auto_detected = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
user = relationship("User", back_populates="named_routes")
|
||||
activities = relationship("Activity", back_populates="named_route")
|
||||
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RouteSegment(Base):
|
||||
"""Named sections within a route for targeted comparisons (e.g. 'The big hill')"""
|
||||
__tablename__ = "route_segments"
|
||||
class Segment(Base):
|
||||
"""A user-defined GPS segment (a stretch of road/trail) matched across activities."""
|
||||
__tablename__ = "segments"
|
||||
|
||||
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)
|
||||
start_distance_m = Column(Float, nullable=False) # distance into route where segment starts
|
||||
end_distance_m = Column(Float, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
sport_type = Column(String(64), nullable=True)
|
||||
polyline = Column(Text, nullable=True) # encoded GPS geometry of the segment
|
||||
start_lat = Column(Float, nullable=True)
|
||||
start_lng = Column(Float, nullable=True)
|
||||
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):
|
||||
@@ -164,38 +225,35 @@ class PersonalRecord(Base):
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
|
||||
sport_type = Column(String(64), nullable=False)
|
||||
distance_m = Column(Float, nullable=False) # e.g. 1000, 1609, 5000, 10000, 42195
|
||||
distance_label = Column(String(32), nullable=False) # e.g. "1k", "1 mile", "5k"
|
||||
distance_m = Column(Float, nullable=False)
|
||||
distance_label = Column(String(32), nullable=False)
|
||||
duration_s = Column(Float, nullable=False)
|
||||
achieved_at = Column(DateTime(timezone=True), nullable=False)
|
||||
is_current_record = Column(Boolean, default=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
|
||||
name="uq_pr_current"),
|
||||
# Uniqueness is enforced at runtime by the partial index uq_pr_current_active
|
||||
# (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),
|
||||
)
|
||||
|
||||
|
||||
class HealthMetric(Base):
|
||||
"""Daily health summary metrics from Garmin Connect / FIT wellness data"""
|
||||
__tablename__ = "health_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
date = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Heart rate
|
||||
resting_hr = Column(Float, nullable=True)
|
||||
max_hr_day = Column(Float, nullable=True)
|
||||
avg_hr_day = Column(Float, nullable=True)
|
||||
|
||||
# HRV
|
||||
hrv_status = Column(String(32), nullable=True) # balanced, unbalanced, etc.
|
||||
hrv_status = Column(String(32), nullable=True)
|
||||
hrv_nightly_avg = Column(Float, nullable=True)
|
||||
hrv_5min_high = Column(Float, nullable=True)
|
||||
hrv_5min_low = Column(Float, nullable=True)
|
||||
|
||||
# Sleep
|
||||
sleep_duration_s = Column(Float, nullable=True)
|
||||
sleep_deep_s = Column(Float, nullable=True)
|
||||
sleep_light_s = Column(Float, nullable=True)
|
||||
@@ -204,26 +262,24 @@ class HealthMetric(Base):
|
||||
sleep_score = Column(Float, nullable=True)
|
||||
sleep_start = Column(DateTime(timezone=True), nullable=True)
|
||||
sleep_end = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Body composition
|
||||
weight_kg = Column(Float, nullable=True)
|
||||
bmi = Column(Float, nullable=True)
|
||||
body_fat_pct = Column(Float, nullable=True)
|
||||
muscle_mass_kg = Column(Float, nullable=True)
|
||||
|
||||
# Fitness
|
||||
vo2max = Column(Float, nullable=True)
|
||||
fitness_age = Column(Integer, nullable=True)
|
||||
training_load = Column(Float, nullable=True)
|
||||
recovery_time_h = Column(Float, nullable=True)
|
||||
|
||||
# Stress & activity
|
||||
avg_stress = Column(Float, nullable=True)
|
||||
steps = Column(Integer, nullable=True)
|
||||
floors_climbed = Column(Integer, nullable=True)
|
||||
active_calories = Column(Float, nullable=True)
|
||||
total_calories = 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__ = (
|
||||
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
||||
|
||||
+365
-318
@@ -1,326 +1,30 @@
|
||||
"""
|
||||
Parses Garmin .fit files and GPX files into normalized activity data.
|
||||
Handles full Strava and Garmin data export archives.
|
||||
FIT and GPX file parser.
|
||||
Parses FIT files directly using the Garmin SDK but applies manual
|
||||
scale conversion for fields where the SDK doesn't auto-convert.
|
||||
"""
|
||||
import os
|
||||
import zipfile
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import fitparse
|
||||
import gpxpy
|
||||
import polyline as polyline_lib
|
||||
from garmin_fit_sdk import Decoder, Stream
|
||||
|
||||
FIT_EPOCH_S = 631065600
|
||||
SEMICIRCLES_TO_DEG = 180.0 / (2 ** 31)
|
||||
|
||||
|
||||
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
||||
"""Returns distance in metres between two GPS points."""
|
||||
R = 6371000
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def semicircles_to_degrees(sc: int) -> float:
|
||||
return sc * (180 / 2**31)
|
||||
|
||||
|
||||
def parse_fit_file(filepath: str) -> dict:
|
||||
"""Parse a Garmin .fit file and return normalized activity dict."""
|
||||
fit = fitparse.FitFile(filepath)
|
||||
|
||||
data_points = []
|
||||
laps = []
|
||||
session = {}
|
||||
|
||||
for record in fit.get_messages():
|
||||
name = record.name
|
||||
|
||||
if name == "session":
|
||||
for f in record:
|
||||
session[f.name] = f.value
|
||||
|
||||
elif name == "lap":
|
||||
lap = {}
|
||||
for f in record:
|
||||
lap[f.name] = f.value
|
||||
laps.append(lap)
|
||||
|
||||
elif name == "record":
|
||||
point = {}
|
||||
for f in record:
|
||||
point[f.name] = f.value
|
||||
if point:
|
||||
# Convert semicircles to degrees
|
||||
if "position_lat" in point and point["position_lat"] is not None:
|
||||
point["position_lat"] = semicircles_to_degrees(point["position_lat"])
|
||||
if "position_long" in point and point["position_long"] is not None:
|
||||
point["position_long"] = semicircles_to_degrees(point["position_long"])
|
||||
data_points.append(point)
|
||||
|
||||
# Build normalized output
|
||||
sport = str(session.get("sport", "generic")).lower()
|
||||
sport_map = {
|
||||
"running": "running", "cycling": "cycling", "swimming": "swimming",
|
||||
"hiking": "hiking", "walking": "walking", "generic": "other",
|
||||
"open_water_swimming": "swimming", "trail_running": "running",
|
||||
}
|
||||
sport_type = sport_map.get(sport, sport)
|
||||
|
||||
start_time = session.get("start_time")
|
||||
if start_time and start_time.tzinfo is None:
|
||||
start_time = start_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Build GPS track for polyline
|
||||
coords = [
|
||||
(p["position_lat"], p["position_long"])
|
||||
for p in data_points
|
||||
if p.get("position_lat") is not None and p.get("position_long") is not None
|
||||
]
|
||||
|
||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
# Calculate cumulative distance if not in FIT
|
||||
cumulative_dist = 0.0
|
||||
prev_lat, prev_lon = None, None
|
||||
normalized_points = []
|
||||
for p in data_points:
|
||||
ts = p.get("timestamp")
|
||||
if ts and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
lat = p.get("position_lat")
|
||||
lon = p.get("position_long")
|
||||
|
||||
dist = p.get("distance")
|
||||
if dist is None and lat and lon and prev_lat and prev_lon:
|
||||
cumulative_dist += haversine_distance(prev_lat, prev_lon, lat, lon)
|
||||
dist = cumulative_dist
|
||||
elif dist is not None:
|
||||
cumulative_dist = float(dist)
|
||||
|
||||
if lat and lon:
|
||||
prev_lat, prev_lon = lat, lon
|
||||
|
||||
normalized_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"altitude_m": p.get("altitude"),
|
||||
"heart_rate": p.get("heart_rate"),
|
||||
"cadence": p.get("cadence"),
|
||||
"speed_ms": p.get("speed"),
|
||||
"power": p.get("power"),
|
||||
"temperature_c": p.get("temperature"),
|
||||
"distance_m": dist,
|
||||
})
|
||||
|
||||
# Parse laps
|
||||
normalized_laps = []
|
||||
for i, lap in enumerate(laps):
|
||||
ls = lap.get("start_time")
|
||||
if ls and ls.tzinfo is None:
|
||||
ls = ls.replace(tzinfo=timezone.utc)
|
||||
normalized_laps.append({
|
||||
"lap_number": i + 1,
|
||||
"start_time": ls.isoformat() if ls else None,
|
||||
"duration_s": _safe_float(lap.get("total_elapsed_time")),
|
||||
"distance_m": _safe_float(lap.get("total_distance")),
|
||||
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
|
||||
"avg_cadence": _safe_float(lap.get("avg_cadence")),
|
||||
"avg_speed_ms": _safe_float(lap.get("avg_speed")),
|
||||
"avg_power": _safe_float(lap.get("avg_power")),
|
||||
})
|
||||
|
||||
return {
|
||||
"name": session.get("sport", "Activity").title() + " " + (
|
||||
start_time.strftime("%Y-%m-%d") if start_time else ""),
|
||||
"sport_type": sport_type,
|
||||
"start_time": start_time.isoformat() if start_time else None,
|
||||
"distance_m": _safe_float(session.get("total_distance")),
|
||||
"duration_s": _safe_float(session.get("total_elapsed_time")),
|
||||
"elevation_gain_m": _safe_float(session.get("total_ascent")),
|
||||
"elevation_loss_m": _safe_float(session.get("total_descent")),
|
||||
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
|
||||
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
|
||||
"avg_cadence": _safe_float(session.get("avg_cadence")),
|
||||
"avg_power": _safe_float(session.get("avg_power")),
|
||||
"normalized_power": _safe_float(session.get("normalized_power")),
|
||||
"avg_speed_ms": _safe_float(session.get("avg_speed")),
|
||||
"max_speed_ms": _safe_float(session.get("max_speed")),
|
||||
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
|
||||
"calories": _safe_float(session.get("total_calories")),
|
||||
"training_stress_score": _safe_float(session.get("training_stress_score")),
|
||||
"vo2max_estimate": _safe_float(session.get("estimated_sweat_loss")), # varies by device
|
||||
"polyline": encoded_polyline,
|
||||
"bounding_box": bounding_box,
|
||||
"source_type": "fit",
|
||||
"data_points": normalized_points,
|
||||
"laps": normalized_laps,
|
||||
}
|
||||
|
||||
|
||||
def parse_gpx_file(filepath: str) -> dict:
|
||||
"""Parse a GPX file into normalized activity dict."""
|
||||
with open(filepath) as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
|
||||
data_points = []
|
||||
track = gpx.tracks[0] if gpx.tracks else None
|
||||
|
||||
if not track:
|
||||
raise ValueError("No tracks found in GPX file")
|
||||
|
||||
for segment in track.segments:
|
||||
for pt in segment.points:
|
||||
ts = pt.time
|
||||
if ts and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
extensions = {}
|
||||
if pt.extensions:
|
||||
for ext in pt.extensions:
|
||||
for child in ext:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
try:
|
||||
extensions[tag] = float(child.text)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
data_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": pt.latitude,
|
||||
"longitude": pt.longitude,
|
||||
"altitude_m": pt.elevation,
|
||||
"heart_rate": extensions.get("hr"),
|
||||
"cadence": extensions.get("cad"),
|
||||
"speed_ms": extensions.get("speed"),
|
||||
"power": extensions.get("power"),
|
||||
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
|
||||
"distance_m": None,
|
||||
})
|
||||
|
||||
# Calculate distance and elevation
|
||||
coords = [(p["latitude"], p["longitude"]) for p in data_points
|
||||
if p["latitude"] and p["longitude"]]
|
||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
# Add cumulative distance
|
||||
total_dist = 0.0
|
||||
prev = None
|
||||
for p in data_points:
|
||||
if p["latitude"] and p["longitude"]:
|
||||
if prev:
|
||||
total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"])
|
||||
prev = (p["latitude"], p["longitude"])
|
||||
p["distance_m"] = total_dist
|
||||
|
||||
uphill, downhill = 0.0, 0.0
|
||||
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
||||
for i in range(1, len(alts)):
|
||||
diff = alts[i] - alts[i-1]
|
||||
if diff > 0:
|
||||
uphill += diff
|
||||
else:
|
||||
downhill += abs(diff)
|
||||
|
||||
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
||||
start_time_str = data_points[0]["timestamp"] if data_points 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
|
||||
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
||||
|
||||
sport = "running" # GPX doesn't always include sport; default to running
|
||||
if track.type:
|
||||
sport = track.type.lower()
|
||||
|
||||
return {
|
||||
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
|
||||
"sport_type": sport,
|
||||
"start_time": start_time_str,
|
||||
"distance_m": total_dist,
|
||||
"duration_s": duration,
|
||||
"elevation_gain_m": uphill,
|
||||
"elevation_loss_m": downhill,
|
||||
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
|
||||
"max_heart_rate": max(hrs) if hrs else None,
|
||||
"avg_cadence": None,
|
||||
"avg_power": None,
|
||||
"normalized_power": None,
|
||||
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
|
||||
"max_speed_ms": None,
|
||||
"avg_temperature_c": None,
|
||||
"calories": None,
|
||||
"training_stress_score": None,
|
||||
"vo2max_estimate": None,
|
||||
"polyline": encoded_polyline,
|
||||
"bounding_box": bounding_box,
|
||||
"source_type": "gpx",
|
||||
"data_points": data_points,
|
||||
"laps": [],
|
||||
}
|
||||
|
||||
|
||||
def parse_strava_export(export_dir: str) -> list[dict]:
|
||||
"""
|
||||
Parse a full Strava data export directory.
|
||||
Structure: activities.csv + activities/ folder with .gpx/.fit.gz files
|
||||
"""
|
||||
activities = []
|
||||
activities_dir = Path(export_dir) / "activities"
|
||||
|
||||
if not activities_dir.exists():
|
||||
return activities
|
||||
|
||||
for fname in sorted(activities_dir.iterdir()):
|
||||
if fname.suffix in (".fit", ".gpx"):
|
||||
try:
|
||||
if fname.suffix == ".fit":
|
||||
act = parse_fit_file(str(fname))
|
||||
else:
|
||||
act = parse_gpx_file(str(fname))
|
||||
act["source_type"] = "strava_" + fname.suffix[1:]
|
||||
activities.append(act)
|
||||
except Exception as e:
|
||||
print(f"Error parsing {fname}: {e}")
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
def calculate_hr_zones(data_points: list[dict], max_hr: float) -> dict:
|
||||
"""Calculate percentage of time spent in each HR zone."""
|
||||
if not max_hr:
|
||||
return {}
|
||||
|
||||
zones = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0}
|
||||
zone_bounds = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
||||
total = 0
|
||||
|
||||
for p in data_points:
|
||||
hr = p.get("heart_rate")
|
||||
if not hr:
|
||||
continue
|
||||
pct = hr / max_hr
|
||||
total += 1
|
||||
if pct < zone_bounds[1]:
|
||||
zones["z1"] += 1
|
||||
elif pct < zone_bounds[2]:
|
||||
zones["z2"] += 1
|
||||
elif pct < zone_bounds[3]:
|
||||
zones["z3"] += 1
|
||||
elif pct < zone_bounds[4]:
|
||||
zones["z4"] += 1
|
||||
else:
|
||||
zones["z5"] += 1
|
||||
|
||||
if total:
|
||||
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||
return {}
|
||||
def _semicircles_to_deg(val):
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
result = float(val) * SEMICIRCLES_TO_DEG
|
||||
if -90 <= result <= 90 or -180 <= result <= 180:
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _safe_float(val) -> Optional[float]:
|
||||
@@ -330,12 +34,355 @@ def _safe_float(val) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _bounding_box(coords: list[tuple]) -> 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
|
||||
|
||||
|
||||
# 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):
|
||||
if not coords:
|
||||
return None
|
||||
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),
|
||||
return {"min_lat": min(lats), "max_lat": max(lats),
|
||||
"min_lon": min(lons), "max_lon": max(lons)}
|
||||
|
||||
|
||||
def _to_dt(val) -> Optional[datetime]:
|
||||
if val is None:
|
||||
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
|
||||
|
||||
|
||||
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 = []
|
||||
laps = []
|
||||
|
||||
def listener(mesg_num: int, msg: dict):
|
||||
if mesg_num == 18: # session
|
||||
session_data.update(msg)
|
||||
elif mesg_num == 20: # record
|
||||
records.append(msg)
|
||||
elif mesg_num == 19: # lap
|
||||
laps.append(msg)
|
||||
|
||||
stream = Stream.from_file(filepath)
|
||||
decoder = Decoder(stream)
|
||||
decoder.read(
|
||||
apply_scale_and_offset=True,
|
||||
convert_datetimes_to_dates=True,
|
||||
convert_types_to_strings=True,
|
||||
enable_crc_check=False,
|
||||
expand_sub_fields=True,
|
||||
expand_components=True,
|
||||
merge_heart_rates=True,
|
||||
mesg_listener=listener,
|
||||
)
|
||||
|
||||
# The SDK may return field names in camelCase or snake_case depending on version.
|
||||
# Try both. Also handle raw timestamp integers for start_time.
|
||||
def get(d, *keys):
|
||||
for k in keys:
|
||||
v = d.get(k)
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
sport_raw = str(get(session_data, "sport", "Sport") or "generic").lower()
|
||||
sport_map = {
|
||||
"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
|
||||
coords = []
|
||||
for r in records:
|
||||
lat = get_lat(r)
|
||||
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
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
# Normalize data points
|
||||
normalized_points = []
|
||||
for r in records:
|
||||
ts = _to_dt(get(r, "timestamp"))
|
||||
lat = get_lat(r)
|
||||
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({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": _safe_float(lat),
|
||||
"longitude": _safe_float(lon),
|
||||
"altitude_m": _safe_float(altitude),
|
||||
"heart_rate": _safe_float(hr),
|
||||
"cadence": _safe_float(cadence),
|
||||
"speed_ms": _safe_float(speed),
|
||||
"power": _safe_float(power),
|
||||
"temperature_c": _safe_float(temp),
|
||||
"distance_m": _safe_float(distance),
|
||||
})
|
||||
|
||||
# Normalize laps
|
||||
normalized_laps = []
|
||||
for i, lap in enumerate(laps):
|
||||
ls = _to_dt(get(lap, "startTime", "start_time"))
|
||||
lap_dist = _safe_float(get(lap, "totalDistance", "total_distance"))
|
||||
lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time"))
|
||||
normalized_laps.append({
|
||||
"lap_number": i + 1,
|
||||
"start_time": ls.isoformat() if ls else None,
|
||||
"duration_s": lap_dur,
|
||||
"distance_m": lap_dist,
|
||||
"avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")),
|
||||
"avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")),
|
||||
"avg_speed_ms": _sanitize_speed(
|
||||
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")),
|
||||
})
|
||||
|
||||
name = sport_type.title()
|
||||
if start_time:
|
||||
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 {
|
||||
"name": name,
|
||||
"sport_type": sport_type,
|
||||
"start_time": start_time.isoformat() if start_time else None,
|
||||
"distance_m": total_dist,
|
||||
"duration_s": elapsed_s,
|
||||
"moving_time_s": moving_s,
|
||||
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
|
||||
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
|
||||
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
|
||||
"max_heart_rate": _safe_float(get(session_data, "maxHeartRate", "max_heart_rate")),
|
||||
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
|
||||
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
|
||||
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
|
||||
"avg_speed_ms": avg_speed,
|
||||
"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,
|
||||
"bounding_box": bounding_box,
|
||||
"source_type": "fit",
|
||||
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
|
||||
"data_points": normalized_points,
|
||||
"laps": normalized_laps,
|
||||
}
|
||||
|
||||
|
||||
def parse_gpx_file(filepath: str) -> dict:
|
||||
with open(filepath) as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
|
||||
data_points = []
|
||||
track = gpx.tracks[0] if gpx.tracks else None
|
||||
if not track:
|
||||
raise ValueError("No tracks found in GPX file")
|
||||
|
||||
for segment in track.segments:
|
||||
for pt in segment.points:
|
||||
ts = pt.time
|
||||
if ts and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
extensions = {}
|
||||
if pt.extensions:
|
||||
for ext in pt.extensions:
|
||||
for child in ext:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
try:
|
||||
extensions[tag] = float(child.text)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
data_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": pt.latitude, "longitude": pt.longitude,
|
||||
"altitude_m": pt.elevation,
|
||||
"heart_rate": extensions.get("hr"),
|
||||
"cadence": extensions.get("cad"),
|
||||
"speed_ms": extensions.get("speed"),
|
||||
"power": extensions.get("power"),
|
||||
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
|
||||
"distance_m": None,
|
||||
})
|
||||
|
||||
coords = [(p["latitude"], p["longitude"]) for p in data_points if p["latitude"] and p["longitude"]]
|
||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
total_dist = 0.0
|
||||
prev = None
|
||||
for p in data_points:
|
||||
if p["latitude"] and p["longitude"]:
|
||||
if prev:
|
||||
R = 6371000
|
||||
phi1, phi2 = math.radians(prev[0]), math.radians(p["latitude"])
|
||||
dphi = math.radians(p["latitude"] - prev[0])
|
||||
dlam = math.radians(p["longitude"] - prev[1])
|
||||
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
||||
total_dist += 2 * R * math.asin(math.sqrt(a))
|
||||
prev = (p["latitude"], p["longitude"])
|
||||
p["distance_m"] = total_dist
|
||||
|
||||
uphill, downhill = 0.0, 0.0
|
||||
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
||||
for i in range(1, len(alts)):
|
||||
diff = alts[i] - alts[i-1]
|
||||
if diff > 0: uphill += diff
|
||||
else: downhill += abs(diff)
|
||||
|
||||
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
||||
start_time_str = data_points[0]["timestamp"] if data_points 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
|
||||
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
||||
sport = track.type.lower() if track.type else "running"
|
||||
gpx_avg_speed = (total_dist / duration) if (total_dist and duration) else None
|
||||
|
||||
return {
|
||||
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
|
||||
"sport_type": sport, "start_time": start_time_str,
|
||||
"distance_m": total_dist, "duration_s": duration, "moving_time_s": None,
|
||||
"elevation_gain_m": uphill, "elevation_loss_m": downhill,
|
||||
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
|
||||
"max_heart_rate": max(hrs) if hrs else None,
|
||||
"avg_cadence": None, "avg_power": None, "normalized_power": None,
|
||||
"avg_speed_ms": gpx_avg_speed,
|
||||
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
|
||||
"training_stress_score": None, "vo2max_estimate": None,
|
||||
"polyline": encoded_polyline, "bounding_box": bounding_box,
|
||||
"source_type": "gpx",
|
||||
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
|
||||
"data_points": data_points, "laps": [],
|
||||
}
|
||||
|
||||
|
||||
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||
if not user_max_hr or user_max_hr < 100:
|
||||
return {}
|
||||
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
|
||||
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
|
||||
zones = {k: 0 for k in zone_keys}
|
||||
total = 0
|
||||
for p in data_points:
|
||||
hr = p.get("heart_rate")
|
||||
if not hr or hr < 20:
|
||||
continue
|
||||
pct = hr / user_max_hr
|
||||
total += 1
|
||||
for i, key in enumerate(zone_keys):
|
||||
if zone_bounds[i] <= pct < zone_bounds[i+1]:
|
||||
zones[key] += 1
|
||||
break
|
||||
else:
|
||||
zones["z5"] += 1
|
||||
if total:
|
||||
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||
return {}
|
||||
@@ -0,0 +1,589 @@
|
||||
"""
|
||||
Garmin Connect sync helpers.
|
||||
|
||||
authenticate_garmin() returns an authenticated client, refreshing the stored
|
||||
OAuth token when possible and falling back to email/password re-login.
|
||||
|
||||
sync_activities() downloads new FIT files and queues them for processing.
|
||||
sync_wellness() pulls daily stats/sleep/HRV summaries from the JSON API
|
||||
and upserts them into health_metrics.
|
||||
"""
|
||||
import io
|
||||
import zipfile
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
def _fernet():
|
||||
import base64, hashlib
|
||||
from cryptography.fernet import Fernet
|
||||
from app.core.config import settings
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(settings.secret_key.encode()).digest())
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_password(password: str) -> str:
|
||||
return _fernet().encrypt(password.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_password(enc: str) -> str:
|
||||
return _fernet().decrypt(enc.encode()).decode()
|
||||
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str]) -> Tuple:
|
||||
"""
|
||||
Returns (garmin_client, new_token_store_or_None).
|
||||
new_token_store is set only when tokens were refreshed/re-created so the
|
||||
caller can persist them.
|
||||
"""
|
||||
import garminconnect
|
||||
|
||||
# Try stored OAuth token first.
|
||||
# Use garth.loads() directly (always treats the argument as an inline string).
|
||||
# garmin.login(tokenstore=...) dispatches on len>512, treating short tokens as
|
||||
# filesystem paths and raising FileNotFoundError on every token-based auth attempt.
|
||||
# After loads(), set display_name from the embedded profile — required by
|
||||
# get_stats(), get_sleep_data(), and other endpoints that build URLs from it.
|
||||
if token_store:
|
||||
try:
|
||||
garmin = garminconnect.Garmin(
|
||||
email=email, password=decrypt_password(password_enc)
|
||||
)
|
||||
garmin.garth.loads(token_store)
|
||||
garmin.display_name = (garmin.garth.profile or {}).get("displayName", "")
|
||||
return garmin, None
|
||||
except Exception as exc:
|
||||
logger.info("Garmin token invalid (%s), re-authenticating", exc)
|
||||
|
||||
# Full login with email + password
|
||||
garmin = garminconnect.Garmin(email=email, password=decrypt_password(password_enc))
|
||||
garmin.login()
|
||||
return garmin, garmin.garth.dumps()
|
||||
|
||||
|
||||
# ── Activity sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
def sync_activities(garmin, user_id: int, since: Optional[datetime],
|
||||
db, file_store_path: str, lookback_days: int = 30,
|
||||
status_callback=None) -> int:
|
||||
"""
|
||||
List activities from Garmin Connect, skip any already in the DB, download
|
||||
FIT ZIPs for new ones, and queue them for processing.
|
||||
|
||||
lookback_days only sets the window on the FIRST sync (since is None):
|
||||
-1 → full history back to 2010
|
||||
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.
|
||||
"""
|
||||
import time
|
||||
from app.workers.tasks import process_activity_file
|
||||
from app.models.user import Activity
|
||||
from sqlalchemy import select, func
|
||||
|
||||
if since:
|
||||
# Incremental: just the recent buffer (cheap, dedup skips already-imported)
|
||||
start_date = (since - timedelta(days=INCREMENTAL_BUFFER_DAYS)).date()
|
||||
elif lookback_days == -1:
|
||||
start_date = date(2010, 1, 1)
|
||||
else:
|
||||
start_date = date.today() - timedelta(days=max(lookback_days, 1))
|
||||
end_date = date.today()
|
||||
|
||||
try:
|
||||
activities = garmin.get_activities_by_date(
|
||||
start_date.isoformat(), end_date.isoformat()
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to list Garmin activities: %s", exc)
|
||||
return 0
|
||||
|
||||
total = len(activities)
|
||||
if status_callback and total:
|
||||
status_callback(f"Syncing activities: 0/{total} queued")
|
||||
|
||||
queued = 0
|
||||
for act in activities:
|
||||
garmin_id = str(act.get("activityId", "")).strip()
|
||||
if not garmin_id:
|
||||
continue
|
||||
|
||||
# Fast path: already imported via Garmin Connect sync
|
||||
existing = db.execute(
|
||||
select(Activity).where(Activity.garmin_activity_id == garmin_id)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Slow-path dedup: activity imported via bulk export (no garmin_activity_id).
|
||||
# Check by start_time; stamp the ID so future syncs skip it in the fast path.
|
||||
act_start_str = act.get("startTimeLocal") or act.get("startTimeGMT") or ""
|
||||
if act_start_str:
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
act_start = _dt.fromisoformat(act_start_str.replace("Z", "+00:00"))
|
||||
time_match = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
func.date(Activity.start_time) == act_start.date(),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if time_match:
|
||||
if not time_match.garmin_activity_id:
|
||||
time_match.garmin_activity_id = garmin_id
|
||||
db.commit()
|
||||
continue
|
||||
except Exception:
|
||||
pass # couldn't parse time — fall through to download
|
||||
|
||||
# Download original FIT (Garmin wraps it in a ZIP)
|
||||
try:
|
||||
zip_bytes = garmin.download_activity(
|
||||
int(garmin_id),
|
||||
dl_fmt=garmin.ActivityDownloadFormat.ORIGINAL,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to download activity %s: %s", garmin_id, exc)
|
||||
continue
|
||||
|
||||
# Extract the FIT from the ZIP
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
|
||||
if not fit_names:
|
||||
logger.debug("No FIT in ZIP for activity %s", garmin_id)
|
||||
continue
|
||||
fit_data = zf.read(fit_names[0])
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to unzip activity %s: %s", garmin_id, exc)
|
||||
continue
|
||||
|
||||
# Save to disk and queue
|
||||
dest_dir = Path(file_store_path) / str(user_id) / "garmin_connect"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / f"{garmin_id}.fit"
|
||||
dest.write_bytes(fit_data)
|
||||
|
||||
process_activity_file.delay(str(dest), user_id, "fit", garmin_id)
|
||||
queued += 1
|
||||
|
||||
if status_callback and (queued % 5 == 0 or queued == total):
|
||||
status_callback(f"Syncing activities: {queued}/{total} queued")
|
||||
|
||||
# Brief pause to avoid hammering the Garmin API
|
||||
time.sleep(0.5)
|
||||
|
||||
return queued
|
||||
|
||||
|
||||
# ── Wellness sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
|
||||
lookback_days: int = 90, status_callback=None) -> int:
|
||||
"""
|
||||
Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each
|
||||
day in the window and upsert into health_metrics.
|
||||
|
||||
lookback_days only sets the window on the FIRST sync (since is None):
|
||||
-1 → full history back to 2010
|
||||
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.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
if since:
|
||||
start_date = (since - timedelta(days=INCREMENTAL_BUFFER_DAYS)).date()
|
||||
elif lookback_days == -1:
|
||||
start_date = date(2010, 1, 1)
|
||||
else:
|
||||
start_date = date.today() - timedelta(days=max(lookback_days, 1))
|
||||
days = (date.today() - start_date).days + 1
|
||||
processed = 0
|
||||
|
||||
import time as _time
|
||||
import json as _json
|
||||
total_days = max(days, 1)
|
||||
if status_callback:
|
||||
status_callback(f"Syncing wellness: 0/{total_days} days")
|
||||
for i in range(total_days):
|
||||
day = start_date + timedelta(days=i)
|
||||
if status_callback and (i % 5 == 0 or i == total_days - 1):
|
||||
status_callback(f"Syncing wellness: {i + 1}/{total_days} days")
|
||||
day_str = day.isoformat()
|
||||
|
||||
stats = _safe(garmin.get_stats, day_str)
|
||||
sleep_data = _safe(garmin.get_sleep_data, day_str)
|
||||
hrv_data = _safe(garmin.get_hrv_data, day_str)
|
||||
# Intraday HR (requires display_name; skip gracefully if absent)
|
||||
hr_raw = _safe(garmin.get_heart_rates, day_str) if garmin.display_name else None
|
||||
bc_data = _safe(garmin.get_body_composition, day_str, day_str)
|
||||
bb_raw = _safe(garmin.get_body_battery, day_str, day_str)
|
||||
_time.sleep(0.25) # avoid hammering Garmin's wellness API
|
||||
|
||||
row = _parse_day(stats, sleep_data, hrv_data)
|
||||
|
||||
# Weight + body composition from weight service (more reliable than stats)
|
||||
if bc_data:
|
||||
entries = (bc_data.get("dateWeightList")
|
||||
or bc_data.get("allWeightMetrics")
|
||||
or bc_data.get("weightList") or [])
|
||||
if entries:
|
||||
e = entries[0]
|
||||
bw = e.get("weight")
|
||||
if bw and float(bw) > 0:
|
||||
bwf = float(bw)
|
||||
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2))
|
||||
if e.get("bmi"):
|
||||
_set(row, "bmi", float(e["bmi"]))
|
||||
if e.get("bodyFat"):
|
||||
_set(row, "body_fat_pct", float(e["bodyFat"]))
|
||||
mm = e.get("muscleMass")
|
||||
if mm and float(mm) > 0:
|
||||
mmf = float(mm)
|
||||
_set(row, "muscle_mass_kg", round(mmf / 1000 if mmf > 300 else mmf, 2))
|
||||
|
||||
# Weight from daily stats as fallback (present when Garmin scale is used)
|
||||
if stats and "weight_kg" not in row:
|
||||
bw = stats.get("bodyWeight")
|
||||
if bw and float(bw) > 0:
|
||||
bwf = float(bw)
|
||||
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2))
|
||||
|
||||
# Body battery — store summary + fine-grained timeline
|
||||
bb = None
|
||||
if bb_raw:
|
||||
bb = _parse_body_battery(bb_raw, day_str)
|
||||
if bb:
|
||||
row["body_battery"] = _json.dumps(bb)
|
||||
|
||||
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs + compute daily averages
|
||||
intraday = None
|
||||
if hr_raw:
|
||||
raw_vals = hr_raw.get("heartRateValues") or []
|
||||
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
|
||||
if intraday:
|
||||
row["intraday_hr"] = intraday
|
||||
hr_vals = [v for _, v in intraday if v > 0]
|
||||
if hr_vals:
|
||||
row["avg_hr_day"] = round(sum(hr_vals) / len(hr_vals), 1)
|
||||
row["max_hr_day"] = float(max(hr_vals))
|
||||
|
||||
# High-resolution body battery derived from BB checkpoints + intraday HR
|
||||
if bb and intraday:
|
||||
hires = _compute_body_battery_hires(bb.get("values") or [], intraday)
|
||||
if hires:
|
||||
row["body_battery_hires"] = _json.dumps(hires)
|
||||
|
||||
if not row:
|
||||
continue
|
||||
|
||||
# psycopg2 treats Python lists/dicts as PG arrays/hstore; serialize JSON
|
||||
# columns as strings so psycopg2 passes them correctly to json/jsonb columns.
|
||||
if "intraday_hr" in row and not isinstance(row["intraday_hr"], str):
|
||||
row["intraday_hr"] = _json.dumps(row["intraday_hr"])
|
||||
if "body_battery" in row and not isinstance(row["body_battery"], str):
|
||||
row["body_battery"] = _json.dumps(row["body_battery"])
|
||||
|
||||
cols = list(row.keys())
|
||||
col_sql = ", ".join(cols)
|
||||
val_sql = ", ".join(f":{c}" for c in cols)
|
||||
upd_sql = ", ".join(
|
||||
# total_calories uses GREATEST so multiple sources don't downgrade
|
||||
f"{c} = GREATEST(EXCLUDED.{c}, health_metrics.{c})"
|
||||
if c == "total_calories" else
|
||||
f"{c} = COALESCE(EXCLUDED.{c}, health_metrics.{c})"
|
||||
for c in cols
|
||||
)
|
||||
|
||||
params = {"user_id": user_id, "day": day.isoformat()}
|
||||
params.update(row)
|
||||
|
||||
try:
|
||||
db.execute(text(f"""
|
||||
INSERT INTO health_metrics (user_id, date, {col_sql})
|
||||
VALUES (:user_id, :day, {val_sql})
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET {upd_sql}
|
||||
"""), params)
|
||||
db.commit()
|
||||
processed += 1
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to upsert health_metrics for %s: %s", day_str, exc)
|
||||
db.rollback()
|
||||
|
||||
# Fetch historical VO2 max across the full sync window via maxmet/daily range query
|
||||
today_str = date.today().isoformat()
|
||||
fa_data = _safe(garmin.get_fitnessage_data, today_str)
|
||||
fa_age = None
|
||||
if fa_data:
|
||||
fa_age = fa_data.get("fitnessAge") or fa_data.get("achievableFitnessAge")
|
||||
|
||||
mm_entries = []
|
||||
try:
|
||||
mm_raw = garmin.connectapi(
|
||||
f"/metrics-service/metrics/maxmet/daily/{start_date.isoformat()}/{today_str}"
|
||||
)
|
||||
logger.info("maxmet range query returned type=%s len=%s",
|
||||
type(mm_raw).__name__,
|
||||
len(mm_raw) if isinstance(mm_raw, (list, dict)) else "n/a")
|
||||
if isinstance(mm_raw, list):
|
||||
mm_entries = mm_raw
|
||||
except Exception as exc:
|
||||
logger.info("maxmet history fetch failed: %s", exc)
|
||||
|
||||
# Each entry has the vo2max data nested under entry["generic"]
|
||||
def _extract_generic(entry):
|
||||
return (entry.get("generic") or {}) if isinstance(entry, dict) else {}
|
||||
|
||||
valid_from_range = any(
|
||||
(_extract_generic(e).get("vo2MaxPreciseValue") or _extract_generic(e).get("vo2MaxValue") or 0)
|
||||
for e in mm_entries
|
||||
)
|
||||
|
||||
# Always fall back to training_status when the range query had no valid data
|
||||
if not valid_from_range:
|
||||
ts_data = _safe(garmin.get_training_status, today_str)
|
||||
generic = ((ts_data or {}).get("mostRecentVO2Max") or {}).get("generic") or {}
|
||||
v = generic.get("vo2MaxPreciseValue") or generic.get("vo2MaxValue")
|
||||
logger.info("training_status vo2max=%s at %s", v, generic.get("calendarDate"))
|
||||
if v and float(v) > 0:
|
||||
mm_entries = [{"generic": {"calendarDate": generic.get("calendarDate") or today_str,
|
||||
"vo2MaxPreciseValue": float(v)}}]
|
||||
|
||||
stored = 0
|
||||
for entry in mm_entries:
|
||||
generic = _extract_generic(entry)
|
||||
v = generic.get("vo2MaxPreciseValue") or generic.get("vo2MaxValue")
|
||||
if not v or float(v) <= 0:
|
||||
continue
|
||||
entry_date = generic.get("calendarDate") or today_str
|
||||
try:
|
||||
fa_row = {"vo2max": float(v)}
|
||||
if fa_age and entry_date == today_str:
|
||||
fa_row["fitness_age"] = int(fa_age)
|
||||
fa_cols = list(fa_row.keys())
|
||||
db.execute(text(f"""
|
||||
INSERT INTO health_metrics (user_id, date, {", ".join(fa_cols)})
|
||||
VALUES (:user_id, :day, {", ".join(f":{c}" for c in fa_cols)})
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||
{", ".join(f"{c} = EXCLUDED.{c}" for c in fa_cols)}
|
||||
"""), {"user_id": user_id, "day": entry_date, **fa_row})
|
||||
db.commit()
|
||||
stored += 1
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to upsert VO2 max for %s: %s", entry_date, exc)
|
||||
db.rollback()
|
||||
|
||||
logger.info("VO2 max: stored=%d from range_valid=%s", stored, valid_from_range)
|
||||
|
||||
return processed
|
||||
|
||||
|
||||
def _parse_body_battery(bb_response, day_str: str):
|
||||
"""Parse get_body_battery() response for a single day into a compact dict."""
|
||||
if not bb_response:
|
||||
return None
|
||||
entry = next((e for e in bb_response if e.get("date") == day_str), None)
|
||||
if not entry and bb_response:
|
||||
entry = bb_response[0]
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
charged = entry.get("charged")
|
||||
drained = entry.get("drained")
|
||||
start_lvl = entry.get("startValue")
|
||||
end_lvl = entry.get("endValue")
|
||||
|
||||
# Fine-grained timeline: [[ts_ms, level, type_code, stress], ...]
|
||||
# type_code: 0=REST, 1=ACTIVE, 2=SLEEP, 3=STRESS, 4=UNMEASURABLE
|
||||
values = entry.get("bodyBatteryValuesArray") or []
|
||||
|
||||
if not values:
|
||||
# Fall back to bodyBatteryStatList (segment-level data)
|
||||
type_map = {"REST": 0, "ACTIVE": 1, "SLEEP": 2, "STRESS": 3, "UNMEASURABLE": 4}
|
||||
for seg in (entry.get("bodyBatteryStatList") or []):
|
||||
ts_str = seg.get("startTimestampGMT") or seg.get("startTimestampLocal")
|
||||
if ts_str:
|
||||
try:
|
||||
from datetime import datetime as _dt, timezone as _tz
|
||||
ts = _dt.fromisoformat(ts_str.rstrip("Z")).replace(tzinfo=_tz.utc)
|
||||
type_code = type_map.get(seg.get("activityType", "UNMEASURABLE"), 4)
|
||||
values.append([int(ts.timestamp() * 1000),
|
||||
int(seg.get("bodyBatteryLevel") or 0),
|
||||
type_code,
|
||||
int(seg.get("stressLevel") or -1)])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if charged is None and end_lvl is None and not values:
|
||||
return None
|
||||
|
||||
return {
|
||||
"charged": charged,
|
||||
"drained": drained,
|
||||
"start_level": start_lvl,
|
||||
"end_level": end_lvl,
|
||||
"values": values, # stripped from list-API, returned in intraday endpoint
|
||||
}
|
||||
|
||||
|
||||
def _compute_body_battery_hires(bb_values, intraday_hr):
|
||||
"""
|
||||
Produce a higher-resolution body battery series by interpolating between
|
||||
sparse BB checkpoints using intraday HR as a proxy for effort.
|
||||
|
||||
During drain segments (BB falling) the drain is distributed proportionally
|
||||
to how much each HR reading exceeds the day's median — peaks spend battery
|
||||
faster than valleys. During recovery segments (BB rising) recovery is
|
||||
spread uniformly over time.
|
||||
|
||||
Returns [[ts_ms, level], ...] at the granularity of intraday HR, or None
|
||||
if inputs are insufficient.
|
||||
"""
|
||||
if not bb_values or not intraday_hr or len(bb_values) < 2:
|
||||
return None
|
||||
|
||||
# Drop entries with None timestamp or level — raw API data can have gaps
|
||||
bb = sorted([v for v in bb_values if v[0] is not None and v[1] is not None],
|
||||
key=lambda x: x[0])
|
||||
if len(bb) < 2:
|
||||
return None
|
||||
hr = sorted(intraday_hr, key=lambda x: x[0])
|
||||
|
||||
hr_vals = [bpm for _, bpm in hr if bpm is not None and bpm > 0]
|
||||
if not hr_vals:
|
||||
return None
|
||||
|
||||
hr_median = sorted(hr_vals)[len(hr_vals) // 2]
|
||||
|
||||
result = []
|
||||
for i in range(len(bb) - 1):
|
||||
t1, L1 = bb[i][0], bb[i][1]
|
||||
t2, L2 = bb[i + 1][0], bb[i + 1][1]
|
||||
delta = L2 - L1
|
||||
|
||||
seg_hr = [(ts, bpm) for ts, bpm in hr if t1 <= ts <= t2 and bpm is not None]
|
||||
result.append([t1, round(float(L1), 1)])
|
||||
|
||||
if not seg_hr or abs(delta) < 1:
|
||||
continue
|
||||
|
||||
if delta < 0:
|
||||
# Drain: weight each reading by HR above median
|
||||
efforts = [max(0.0, bpm - hr_median) for _, bpm in seg_hr]
|
||||
total = sum(efforts) or 1.0
|
||||
cumul = 0.0
|
||||
for j, (ts, bpm) in enumerate(seg_hr):
|
||||
cumul += efforts[j] * delta / total
|
||||
level = max(0.0, min(100.0, L1 + cumul))
|
||||
result.append([ts, round(level, 1)])
|
||||
else:
|
||||
# Recovery: linear over time
|
||||
span = max(1, t2 - t1)
|
||||
for ts, _ in seg_hr:
|
||||
frac = (ts - t1) / span
|
||||
level = max(0.0, min(100.0, L1 + delta * frac))
|
||||
result.append([ts, round(level, 1)])
|
||||
|
||||
result.append([bb[-1][0], round(float(bb[-1][1]), 1)])
|
||||
|
||||
# Deduplicate and sort
|
||||
seen, out = set(), []
|
||||
for item in sorted(result, key=lambda x: x[0]):
|
||||
if item[0] not in seen:
|
||||
seen.add(item[0])
|
||||
out.append(item)
|
||||
|
||||
return out if len(out) > 4 else None
|
||||
|
||||
|
||||
def _safe(fn, *args):
|
||||
try:
|
||||
return fn(*args)
|
||||
except Exception as exc:
|
||||
logger.debug("%s(%s) skipped: %s", fn.__name__, args, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
||||
row = {}
|
||||
|
||||
if stats:
|
||||
_set(row, "resting_hr", stats.get("restingHeartRate"))
|
||||
# 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, "steps", stats.get("totalSteps"))
|
||||
_set(row, "floors_climbed", stats.get("floorsAscended"))
|
||||
_set(row, "avg_stress", stats.get("averageStressLevel"))
|
||||
active = stats.get("activeKilocalories")
|
||||
bmr = stats.get("bmrKilocalories")
|
||||
_set(row, "active_calories", active)
|
||||
if active and bmr:
|
||||
_set(row, "total_calories", float(active) + float(bmr))
|
||||
|
||||
if sleep_data:
|
||||
dto = sleep_data.get("dailySleepDTO") or sleep_data
|
||||
_set(row, "sleep_duration_s", dto.get("sleepTimeSeconds"))
|
||||
_set(row, "sleep_deep_s", dto.get("deepSleepSeconds"))
|
||||
_set(row, "sleep_light_s", dto.get("lightSleepSeconds"))
|
||||
_set(row, "sleep_rem_s", dto.get("remSleepSeconds"))
|
||||
_set(row, "sleep_awake_s", dto.get("awakeSleepSeconds"))
|
||||
|
||||
# Timestamps are milliseconds since epoch in local time
|
||||
for key, col in (("sleepStartTimestampLocal", "sleep_start"),
|
||||
("sleepEndTimestampLocal", "sleep_end")):
|
||||
ms = dto.get(key)
|
||||
if ms:
|
||||
_set(row, col, datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat())
|
||||
|
||||
# SpO2
|
||||
spo2 = dto.get("averageSpO2Value")
|
||||
if spo2 and 50 < float(spo2) <= 100:
|
||||
row["spo2_avg"] = float(spo2)
|
||||
|
||||
# Sleep score — Garmin nests it under dailySleepDTO.sleepScores on most
|
||||
# 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):
|
||||
overall = scores.get("overall") or scores.get("qualityScore")
|
||||
if isinstance(overall, dict):
|
||||
_set(row, "sleep_score", overall.get("value"))
|
||||
else:
|
||||
_set(row, "sleep_score", overall)
|
||||
elif isinstance(scores, (int, float)):
|
||||
row["sleep_score"] = scores
|
||||
|
||||
if hrv_data:
|
||||
summary = hrv_data.get("hrvSummary") or hrv_data
|
||||
_set(row, "hrv_nightly_avg", summary.get("lastNight") or summary.get("lastNightAvg"))
|
||||
_set(row, "hrv_5min_high", summary.get("lastNight5MinHigh"))
|
||||
status = summary.get("status")
|
||||
if status:
|
||||
row["hrv_status"] = str(status).lower()
|
||||
|
||||
return row
|
||||
|
||||
|
||||
def _set(d: dict, key: str, val):
|
||||
if val is not None:
|
||||
d[key] = val
|
||||
@@ -63,11 +63,21 @@ def routes_are_similar(
|
||||
bb1: Optional[dict],
|
||||
bb2: Optional[dict],
|
||||
dtw_threshold_m: float = 80.0,
|
||||
dist1: Optional[float] = None,
|
||||
dist2: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if two activities are on sufficiently similar routes.
|
||||
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 not bounding_boxes_overlap(bb1, bb2):
|
||||
return False
|
||||
@@ -85,39 +95,72 @@ def routes_are_similar(
|
||||
return dist < dtw_threshold_m
|
||||
|
||||
|
||||
def find_segment_times(
|
||||
data_points: list[dict],
|
||||
start_dist_m: float,
|
||||
end_dist_m: float,
|
||||
def match_segment_in_activity(
|
||||
seg_coords: list[tuple],
|
||||
act_coords: list[tuple],
|
||||
act_times: list,
|
||||
tol_m: float = 30.0,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Given activity data points (with cumulative distance_m),
|
||||
find the time to traverse from start_dist_m to end_dist_m.
|
||||
Returns duration in seconds, or None if not found.
|
||||
"""
|
||||
start_time = None
|
||||
end_time = None
|
||||
Determine whether an activity track traverses a segment's GPS geometry in the
|
||||
segment's own direction, and if so how long the fastest such traversal took.
|
||||
Works even when the activity's overall route differs — only the overlapping
|
||||
stretch matters.
|
||||
|
||||
for p in data_points:
|
||||
dist = p.get("distance_m")
|
||||
ts = p.get("timestamp")
|
||||
if dist is None or ts is None:
|
||||
seg_coords: [(lat, lon), ...] segment geometry (start → end).
|
||||
act_coords: [(lat, lon), ...] activity track, in time order.
|
||||
act_times: parallel list of datetimes for act_coords.
|
||||
|
||||
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
|
||||
|
||||
if start_time is None and dist >= start_dist_m:
|
||||
start_time = ts
|
||||
# Confirm the activity follows the segment shape in order between the anchors.
|
||||
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:
|
||||
end_time = ts
|
||||
break
|
||||
dur = (act_times[ei] - act_times[si]).total_seconds()
|
||||
if dur > 0 and (best is None or dur < best):
|
||||
best = dur
|
||||
|
||||
if start_time and end_time:
|
||||
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
|
||||
return best
|
||||
|
||||
|
||||
def find_best_split_time(
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Sleep stages: message 275 (modern) or 269 (older) each carry a start timestamp
|
||||
and a stage name. Duration of each stage = gap to the next stage's timestamp.
|
||||
The sleep session stop time (from event message 21, event_type='stop') closes
|
||||
the last stage.
|
||||
"""
|
||||
from datetime import datetime, timezone, date
|
||||
from typing import Optional
|
||||
from garmin_fit_sdk import Decoder, Stream
|
||||
|
||||
|
||||
FIT_EPOCH_S = 631065600
|
||||
SLEEP_LEVEL_MAP = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
|
||||
|
||||
|
||||
def _fit_ts(raw) -> Optional[datetime]:
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
s = int(raw)
|
||||
if s <= 0 or s == 0xFFFFFFFF:
|
||||
return None
|
||||
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_date(val) -> Optional[date]:
|
||||
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:
|
||||
"""
|
||||
Parse a Garmin wellness/monitoring FIT file.
|
||||
Returns {"days": {date: metrics_dict}, "error": str|None}
|
||||
"""
|
||||
daily = {}
|
||||
last_date_seen = [None]
|
||||
|
||||
def ensure_day(d: date) -> dict:
|
||||
if d not in daily:
|
||||
daily[d] = {
|
||||
"heart_rates": [],
|
||||
"stress_values": [],
|
||||
"spo2_readings": [],
|
||||
# Each entry: (datetime, level_int) — duration computed from gaps
|
||||
"sleep_epochs": [],
|
||||
"sleep_start": None,
|
||||
"sleep_end": None,
|
||||
"steps": None,
|
||||
"floors_climbed": None,
|
||||
"active_calories": None,
|
||||
"bmr": None,
|
||||
"resting_hr": None,
|
||||
"hrv_nightly_avg": None,
|
||||
"hrv_5min_high": None,
|
||||
"hrv_status": None,
|
||||
"sleep_score": None,
|
||||
}
|
||||
return daily[d]
|
||||
|
||||
def _add_sleep_epoch(ts: datetime, level_raw):
|
||||
d = _to_date(ts)
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
if isinstance(level_raw, str):
|
||||
level = SLEEP_LEVEL_MAP.get(level_raw.lower())
|
||||
else:
|
||||
level = level_raw
|
||||
if level is not None:
|
||||
ensure_day(d)["sleep_epochs"].append((ts, int(level)))
|
||||
|
||||
def listener(mesg_num: int, msg: dict):
|
||||
|
||||
# ── monitoring_info (147) - older firmware ─────────────────────────
|
||||
if mesg_num == 147:
|
||||
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:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
hr = msg.get("heart_rate")
|
||||
if hr and 20 < hr < 250:
|
||||
entry["heart_rates"].append(int(hr))
|
||||
steps = msg.get("steps") or msg.get("cycles")
|
||||
if steps and steps > 0:
|
||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||
stress = msg.get("stress_level_value")
|
||||
if stress is not None and stress >= 0:
|
||||
entry["stress_values"].append(int(stress))
|
||||
|
||||
# ── monitoring (55) - modern, per-interval running totals ──────────
|
||||
elif mesg_num == 55:
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
hr = msg.get("heart_rate")
|
||||
if hr and 20 < hr < 250:
|
||||
entry["heart_rates"].append(int(hr))
|
||||
steps = msg.get("steps")
|
||||
if steps and steps > 0:
|
||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||
active_cal = msg.get("active_calories")
|
||||
if active_cal and active_cal > 0:
|
||||
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:
|
||||
entry["hrv_status"] = str(status)
|
||||
|
||||
# ── message 275 - sleep epochs (modern) or HRV (older firmware) ───
|
||||
elif mesg_num == 275:
|
||||
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:
|
||||
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")
|
||||
if stress is not None and stress >= 0:
|
||||
ensure_day(d)["stress_values"].append(int(stress))
|
||||
|
||||
# ── spo2_data (258) ────────────────────────────────────────────────
|
||||
elif mesg_num == 258:
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
|
||||
if spo2 and 50 < spo2 <= 100:
|
||||
ensure_day(d)["spo2_readings"].append(float(spo2))
|
||||
|
||||
# ── per-minute stress + HR (227) proprietary ───────────────────────
|
||||
elif mesg_num == 227:
|
||||
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
hr_raw = msg.get(2)
|
||||
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
|
||||
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))
|
||||
|
||||
# ── daily resting HR (211) proprietary ─────────────────────────────
|
||||
elif mesg_num == 211:
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
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:
|
||||
entry["resting_hr"] = int(rhr)
|
||||
|
||||
try:
|
||||
stream = Stream.from_file(file_path)
|
||||
decoder = Decoder(stream)
|
||||
messages, errors = decoder.read(
|
||||
apply_scale_and_offset=True,
|
||||
convert_datetimes_to_dates=True,
|
||||
convert_types_to_strings=True,
|
||||
enable_crc_check=False,
|
||||
expand_sub_fields=True,
|
||||
expand_components=True,
|
||||
merge_heart_rates=False,
|
||||
mesg_listener=listener,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e), "days": {}}
|
||||
|
||||
result = {}
|
||||
for day_date, data in daily.items():
|
||||
hrs = data.pop("heart_rates", [])
|
||||
stresses = data.pop("stress_values", [])
|
||||
spo2s = data.pop("spo2_readings", [])
|
||||
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
|
||||
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
|
||||
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
|
||||
|
||||
# Compute sleep stage durations from epoch timestamps
|
||||
if sleep_epochs:
|
||||
epochs_sorted = sorted(sleep_epochs, key=lambda x: x[0])
|
||||
level_secs = {1: 0, 2: 0, 3: 0, 4: 0} # awake, light, deep, rem
|
||||
for i, (ts, level) in enumerate(epochs_sorted):
|
||||
if i + 1 < len(epochs_sorted):
|
||||
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:
|
||||
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] = {
|
||||
"resting_hr": data.get("resting_hr"),
|
||||
"avg_hr_day": avg_hr,
|
||||
"max_hr_day": max_hr,
|
||||
"avg_stress": avg_stress,
|
||||
"spo2_avg": spo2_avg,
|
||||
"hrv_nightly_avg": data.get("hrv_nightly_avg"),
|
||||
"hrv_5min_high": data.get("hrv_5min_high"),
|
||||
"hrv_status": data.get("hrv_status"),
|
||||
"steps": data.get("steps"),
|
||||
"floors_climbed": data.get("floors_climbed"),
|
||||
"active_calories": active_cal,
|
||||
"total_calories": total_cal,
|
||||
"sleep_duration_s": sleep_duration_s,
|
||||
"sleep_deep_s": sleep_deep_s,
|
||||
"sleep_light_s": sleep_light_s,
|
||||
"sleep_rem_s": sleep_rem_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}
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Celery entry point. Re-exports celery_app from tasks so the worker
|
||||
can be started with: celery -A app.workers.celery_app worker
|
||||
"""
|
||||
from app.workers.tasks import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
+782
-172
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,13 @@ alembic==1.13.1
|
||||
pydantic==2.7.1
|
||||
pydantic-settings==2.2.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
passlib==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.9
|
||||
httpx==0.27.0
|
||||
redis[hiredis]==5.0.4
|
||||
celery[redis]==5.4.0
|
||||
fitparse==1.2.0
|
||||
garmin-fit-sdk==21.195.0
|
||||
gpxpy==1.6.2
|
||||
numpy==1.26.4
|
||||
scipy==1.13.0
|
||||
@@ -21,3 +22,6 @@ Pillow==10.3.0
|
||||
aiofiles==23.2.1
|
||||
python-dateutil==2.9.0
|
||||
pytz==2024.1
|
||||
psycopg2-binary==2.9.9
|
||||
garminconnect==0.2.24
|
||||
cryptography==42.0.8
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
MESSAGE="${1:-update}"
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
git add -A
|
||||
git commit -m "$MESSAGE"
|
||||
git push
|
||||
|
||||
cd ../milevault_docker
|
||||
docker compose down
|
||||
|
||||
echo ""
|
||||
echo "Done. Run 'docker compose pull && docker compose up -d' when the build completes."
|
||||
@@ -91,6 +91,24 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
beat:
|
||||
image: gitea.yourdomain.com/yourusername/milevault-worker:latest
|
||||
container_name: milevault_beat
|
||||
restart: unless-stopped
|
||||
command: celery -A app.workers.celery_app beat --loglevel=info
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
||||
FILE_STORE_PATH: /data/files
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: gitea.yourdomain.com/yourusername/milevault-frontend:latest
|
||||
container_name: milevault_frontend
|
||||
|
||||
@@ -83,6 +83,26 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
beat:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.worker
|
||||
container_name: milevault_beat
|
||||
restart: unless-stopped
|
||||
command: celery -A app.workers.celery_app beat --loglevel=info
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||
FILE_STORE_PATH: /data/files
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
ARG VITE_API_URL=/api
|
||||
@@ -15,4 +15,4 @@ RUN npm run build
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
EXPOSE 80
|
||||
Generated
+3527
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -9,25 +9,25 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^2.12.7",
|
||||
"date-fns": "^3.6.0",
|
||||
"clsx": "^2.1.1",
|
||||
"zustand": "^4.5.2",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"axios": "^1.7.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"@polyline-codec/core": "^2.0.0"
|
||||
"react-grid-layout": "^1.5.3",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"recharts": "^2.12.7",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.2.13",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4"
|
||||
"tailwindcss": "^3.4.4",
|
||||
"vite": "^5.2.13"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-19
@@ -10,6 +10,8 @@ import HealthPage from './pages/HealthPage'
|
||||
import RoutesPage from './pages/RoutesPage'
|
||||
import RecordsPage from './pages/RecordsPage'
|
||||
import UploadPage from './pages/UploadPage'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
import UsersPage from './pages/UsersPage'
|
||||
|
||||
function RequireAuth({ children }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
@@ -24,28 +26,10 @@ export default function App() {
|
||||
if (token) fetchUser()
|
||||
}, [token])
|
||||
|
||||
// Handle token from PocketID callback URL
|
||||
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 (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="activities" element={<ActivitiesPage />} />
|
||||
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
||||
@@ -53,6 +37,8 @@ export default function App() {
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="records" element={<RecordsPage />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { sportColor } from '../../utils/format'
|
||||
import { projectToTrack } from '../../utils/track'
|
||||
|
||||
// Fix Leaflet default icon issue with bundlers
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
@@ -10,38 +10,153 @@ L.Icon.Default.mergeOptions({
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
})
|
||||
|
||||
const TILE_LAYERS = {
|
||||
dark: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
},
|
||||
street: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
},
|
||||
satellite: {
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© <a href="https://www.esri.com/">Esri</a>',
|
||||
},
|
||||
}
|
||||
|
||||
// Tile options tuned for smoother panning/zooming: keep a larger off-screen
|
||||
// buffer of tiles and don't defer loads until the map is idle.
|
||||
const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false }
|
||||
|
||||
// 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) {
|
||||
// Simple polyline decoder
|
||||
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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType }) {
|
||||
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: '',
|
||||
})
|
||||
|
||||
|
||||
function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
|
||||
if (trackRef.current) {
|
||||
trackRef.current.remove()
|
||||
trackRef.current = null
|
||||
}
|
||||
|
||||
// Prefer the data-point track when colouring by speed; fall back to the encoded polyline.
|
||||
const speedPts = (colorMode === 'speed' && dataPoints)
|
||||
? dataPoints.filter(p => p.latitude != null && p.longitude != null)
|
||||
: []
|
||||
|
||||
const group = L.layerGroup()
|
||||
|
||||
if (speedPts.length >= 2 && speedPts.some(p => p.speed_ms != null)) {
|
||||
const speeds = speedPts.map(p => p.speed_ms).filter(s => s != null && s > 0)
|
||||
speeds.sort((a, b) => a - b)
|
||||
// Clamp the range to the 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)
|
||||
if (!coords.length) return
|
||||
L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }).addTo(group)
|
||||
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
|
||||
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
|
||||
group.addTo(map)
|
||||
trackRef.current = group
|
||||
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
|
||||
}
|
||||
|
||||
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'street', colorMode = 'speed', onMapClick }) {
|
||||
const mapRef = useRef(null)
|
||||
const mapInstanceRef = useRef(null)
|
||||
const markerRef = useRef(null)
|
||||
const segTargetRef = useRef(null)
|
||||
const trackRef = useRef(null)
|
||||
const tileLayerRef = useRef(null)
|
||||
const drawArgsRef = useRef({ polyline, dataPoints, sportType, colorMode })
|
||||
const clickRef = useRef(onMapClick)
|
||||
|
||||
drawArgsRef.current = { polyline, dataPoints, sportType, colorMode }
|
||||
useEffect(() => { clickRef.current = onMapClick }, [onMapClick])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return
|
||||
@@ -49,16 +164,34 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
||||
mapInstanceRef.current = L.map(mapRef.current, {
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
preferCanvas: true,
|
||||
})
|
||||
|
||||
// Use CartoDB dark tiles (no API key needed)
|
||||
L.tileLayer(
|
||||
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
{
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
const tile = TILE_LAYERS.street
|
||||
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
|
||||
.addTo(mapInstanceRef.current)
|
||||
|
||||
mapInstanceRef.current.on('click', (e) => {
|
||||
if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng })
|
||||
})
|
||||
|
||||
// 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 = projectToTrack(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)
|
||||
}
|
||||
).addTo(mapInstanceRef.current)
|
||||
})
|
||||
mapInstanceRef.current.on('mouseout', () => {
|
||||
if (segTargetRef.current) { segTargetRef.current.remove(); segTargetRef.current = null }
|
||||
})
|
||||
|
||||
return () => {
|
||||
mapInstanceRef.current?.remove()
|
||||
@@ -66,47 +199,31 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Draw route when polyline changes
|
||||
// Clear the target dot when leaving segment-create mode.
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !polyline) return
|
||||
|
||||
if (trackRef.current) {
|
||||
trackRef.current.remove()
|
||||
if (!onMapClick && segTargetRef.current) {
|
||||
segTargetRef.current.remove()
|
||||
segTargetRef.current = null
|
||||
}
|
||||
}, [onMapClick])
|
||||
|
||||
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] })
|
||||
|
||||
// Start/end markers
|
||||
if (coords.length > 0) {
|
||||
const startIcon = L.divIcon({
|
||||
html: '<div style="width:12px;height:12px;background:#22c55e;border:2px solid white;border-radius:50%"></div>',
|
||||
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
||||
})
|
||||
const endIcon = L.divIcon({
|
||||
html: '<div style="width:12px;height:12px;background:#ef4444;border:2px solid white;border-radius:50%"></div>',
|
||||
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
||||
})
|
||||
L.marker(coords[0], { icon: startIcon }).addTo(mapInstanceRef.current)
|
||||
L.marker(coords[coords.length - 1], { icon: endIcon }).addTo(mapInstanceRef.current)
|
||||
}
|
||||
}, [polyline, sportType])
|
||||
|
||||
// Move position marker when timeline is hovered
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
|
||||
if (!mapInstanceRef.current) return
|
||||
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street
|
||||
if (tileLayerRef.current) tileLayerRef.current.remove()
|
||||
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
|
||||
.addTo(mapInstanceRef.current)
|
||||
}, [mapType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
drawRoute(mapInstanceRef.current, drawArgsRef.current, trackRef)
|
||||
}, [polyline, sportType, colorMode, dataPoints])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
|
||||
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
|
||||
if (!point?.latitude || !point?.longitude) return
|
||||
|
||||
if (markerRef.current) {
|
||||
markerRef.current.setLatLng([point.latitude, point.longitude])
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatDuration } from '../../utils/format'
|
||||
|
||||
const ZONE_CONFIG = [
|
||||
{ key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
|
||||
{ key: 'z2', label: 'Z2 Base', color: '#34d399' },
|
||||
@@ -6,7 +8,9 @@ const ZONE_CONFIG = [
|
||||
{ key: 'z5', label: 'Z5 Max', color: '#f43f5e' },
|
||||
]
|
||||
|
||||
export default function HRZoneBar({ zones }) {
|
||||
// zones holds the % of time in each zone; multiply by the activity's active time
|
||||
// to show the approximate time spent in each.
|
||||
export default function HRZoneBar({ zones, totalSeconds }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Stacked bar */}
|
||||
@@ -34,6 +38,9 @@ export default function HRZoneBar({ zones }) {
|
||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
|
||||
<span className="text-xs text-gray-400">{label}</span>
|
||||
<span className="text-xs font-medium text-white">{pct}%</span>
|
||||
{totalSeconds > 0 && (
|
||||
<span className="text-xs text-gray-500">{formatDuration(Math.round((pct / 100) * totalSeconds))}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { formatDuration, formatDistance, formatPace, formatHeartRate } from '../../utils/format'
|
||||
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
|
||||
|
||||
export default function LapTable({ laps, sportType }) {
|
||||
const RUNNING_TYPES = new Set(['running', 'hiking', 'walking'])
|
||||
|
||||
export default function LapTable({ laps, sportType, lapBests }) {
|
||||
const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
|
||||
const hasBests = lapBests && Object.keys(lapBests).length > 0
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -9,30 +13,49 @@ export default function LapTable({ laps, sportType }) {
|
||||
<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">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">Avg HR</th>
|
||||
<th className="text-right pb-2 font-medium">Cadence</th>
|
||||
<th className="text-right pb-2 font-medium">Power</th>
|
||||
{showPower && <th className="text-right pb-2 font-medium">Power</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{laps.map((lap) => (
|
||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</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 className="py-2 text-right text-gray-400">
|
||||
{lap.avg_cadence ? `${Math.round(lap.avg_cadence)} rpm` : '--'}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{laps.map((lap) => {
|
||||
const best = hasBests ? lapBests[String(lap.lap_number)] : null
|
||||
const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null
|
||||
const isBest = delta != null && delta <= 0.5
|
||||
return (
|
||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
||||
<td className={`py-2 text-right ${isBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
|
||||
{hasBests && (
|
||||
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
|
||||
)}
|
||||
{hasBests && (
|
||||
<td className={`py-2 text-right font-mono ${
|
||||
delta == null ? 'text-gray-700' : isBest ? 'text-yellow-400' : delta < 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
{delta == null ? '--' : isBest ? '🏆' : `${delta > 0 ? '+' : '−'}${formatDuration(Math.abs(delta))}`}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, ReferenceLine,
|
||||
ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { formatDuration, formatPace } 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) {
|
||||
if (points.length <= maxPoints) return points
|
||||
@@ -11,33 +27,43 @@ function downsample(points, maxPoints = 500) {
|
||||
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
|
||||
.filter(p => p.timestamp)
|
||||
.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) {
|
||||
row[key] = p[key] ?? null
|
||||
row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
|
||||
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, useTimeAxis }) => {
|
||||
if (!active || !payload?.length) return null
|
||||
|
||||
if (onHover) onHover(label)
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
|
||||
<p className="text-gray-400 mb-1">{(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 => {
|
||||
const metric = metrics.find(m => m.key === entry.dataKey)
|
||||
if (!metric || entry.value == null) return null
|
||||
let display = entry.value.toFixed(1)
|
||||
if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType)
|
||||
else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm`
|
||||
else if (entry.dataKey === 'cadence') display = `${Math.round(entry.value)} rpm`
|
||||
else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType)
|
||||
else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W`
|
||||
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
|
||||
else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m`
|
||||
@@ -54,26 +80,39 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover })
|
||||
}
|
||||
|
||||
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(() =>
|
||||
downsample(buildChartData(dataPoints, activeMetrics)),
|
||||
[dataPoints, activeMetrics]
|
||||
downsample(buildChartData(dataPoints, activeMetrics, useTimeAxis)),
|
||||
[dataPoints, activeMetrics, useTimeAxis]
|
||||
)
|
||||
|
||||
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
|
||||
|
||||
// Build per-metric Y-axis domains
|
||||
const domains = useMemo(() => {
|
||||
const result = {}
|
||||
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
|
||||
// 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 max = Math.max(...vals)
|
||||
const pad = (max - min) * 0.1 || 1
|
||||
result[m.key] = [min - pad, max + pad]
|
||||
}
|
||||
return result
|
||||
}, [chartData, activeMetricConfigs])
|
||||
}, [chartData, activeMetricConfigs, sportType])
|
||||
|
||||
if (!chartData.length) {
|
||||
return (
|
||||
@@ -87,27 +126,23 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
||||
<div className="space-y-4">
|
||||
{activeMetricConfigs.map((metric, idx) => {
|
||||
const domain = domains[metric.key] || ['auto', 'auto']
|
||||
const data = chartData.filter(p => p[metric.key] != null)
|
||||
if (!data.length) return null
|
||||
const hasData = chartData.some(p => p[metric.key] != null)
|
||||
if (!hasData) return null
|
||||
|
||||
return (
|
||||
<div key={metric.key}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span style={{ color: metric.color }} className="text-xs font-medium">
|
||||
{metric.label}
|
||||
</span>
|
||||
{metric.unit && (
|
||||
<span className="text-xs text-gray-600">({metric.unit})</span>
|
||||
)}
|
||||
<span style={{ color: metric.color }} className="text-xs font-medium">{metric.label}</span>
|
||||
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
|
||||
</div>
|
||||
<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} />
|
||||
<XAxis
|
||||
dataKey="distance_m"
|
||||
dataKey="x"
|
||||
type="number"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={v => `${(v / 1000).toFixed(1)}`}
|
||||
tickFormatter={v => useTimeAxis ? fmtSeconds(v) : `${(v / 1000).toFixed(1)}`}
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
@@ -118,39 +153,45 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={36}
|
||||
width={40}
|
||||
tickFormatter={v => {
|
||||
if (metric.key === 'speed_ms') return `${(v * 3.6).toFixed(0)}`
|
||||
if (metric.key === 'speed_ms') {
|
||||
if (v <= 0 || v > 25) return ''
|
||||
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
||||
const spm = 1000 / v
|
||||
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
|
||||
}
|
||||
if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1))
|
||||
return Math.round(v)
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<CustomTooltip
|
||||
metrics={metrics}
|
||||
sportType={sportType}
|
||||
onHover={onHoverDistance}
|
||||
/>
|
||||
}
|
||||
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} useTimeAxis={useTimeAxis} />}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={metric.key}
|
||||
stroke={metric.color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls={false}
|
||||
/>
|
||||
{metric.key === 'cadence' && sportType === 'running' ? (
|
||||
<>
|
||||
{/* 165 spm guide → 82.5 in stored (halved) units */}
|
||||
<ReferenceLine y={82.5} stroke="#22c55e" strokeDasharray="4 4" strokeWidth={1.5} />
|
||||
<Scatter dataKey="cadence" shape={renderCadenceDot} isAnimationActive={false} />
|
||||
</>
|
||||
) : (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={metric.key}
|
||||
stroke={metric.color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls={false}
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Shared distance axis label */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { formatDuration, formatDate } from '../../utils/format'
|
||||
|
||||
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
|
||||
|
||||
// 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 <= 3 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
{MEDALS[e.rank] || 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,146 @@
|
||||
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: '🥉' }
|
||||
const PLACE_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 <= 3 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
{MEDALS[e.rank] || 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">{PLACE_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,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../hooks/useAuth'
|
||||
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: 'Dashboard', icon: '📊', exact: true },
|
||||
@@ -8,64 +10,121 @@ const nav = [
|
||||
{ to: '/routes', label: 'Routes', icon: '🗺️' },
|
||||
{ to: '/records', label: 'Records', icon: '🏆' },
|
||||
{ to: '/upload', label: 'Import', icon: '⬆️' },
|
||||
{ to: '/profile', label: 'Profile', icon: '⚙️' },
|
||||
{ to: '/users', label: 'Users', icon: '👥', adminOnly: true },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuthStore()
|
||||
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 = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const role = user?.is_admin ? 'Administrator' : 'Member'
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-gray-950">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||
{/* Logo */}
|
||||
<div className="px-4 py-5 border-b border-gray-800">
|
||||
<h1 className="text-lg font-bold text-white tracking-tight">
|
||||
<span className="text-blue-400">Mile</span>Vault
|
||||
</h1>
|
||||
{user && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">@{user.username}</p>
|
||||
<aside className={`${collapsed ? 'w-16' : 'w-56'} flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col transition-[width] duration-200`}>
|
||||
<div className={`flex items-center border-b border-gray-800 px-3 py-5 ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
||||
{!collapsed && (
|
||||
<h1 className="text-lg font-bold text-white tracking-tight">
|
||||
<span className="text-blue-400">Mile</span>Vault
|
||||
</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>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
{nav.map(({ to, label, icon, exact }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={exact}
|
||||
{nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ to, label, icon, exact }) => (
|
||||
<NavLink key={to} to={to} end={exact} title={collapsed ? label : undefined}
|
||||
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
|
||||
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400'
|
||||
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
|
||||
}`
|
||||
}
|
||||
>
|
||||
}>
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
{!collapsed && label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-4 border-t border-gray-800">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
{inProgress && !collapsed && (
|
||||
<div className="px-4 py-3 border-t border-gray-800 space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-xs text-blue-400">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Garmin sync
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full transition-all duration-700"
|
||||
style={{ width: `${syncProgressPct(status)}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const accentColors = {
|
||||
|
||||
export default function StatCard({ label, value, accent = 'default', sub }) {
|
||||
return (
|
||||
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50">
|
||||
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50 h-full flex flex-col justify-center">
|
||||
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||
<p className={`text-lg font-semibold ${accentColors[accent]}`}>{value}</p>
|
||||
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { create } from 'zustand'
|
||||
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) => ({
|
||||
token: localStorage.getItem('token'),
|
||||
token: initialToken,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
@@ -23,12 +33,10 @@ export const useAuthStore = create((set) => ({
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('token')
|
||||
set({ token: null, user: null })
|
||||
},
|
||||
|
||||
fetchUser: async () => {
|
||||
try {
|
||||
const { data } = await api.get('/auth/me')
|
||||
@@ -38,4 +46,4 @@ export const useAuthStore = create((set) => ({
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
},
|
||||
}))
|
||||
}))
|
||||
@@ -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,33 +1,48 @@
|
||||
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 { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||
formatDate, sportIcon, sportColor,
|
||||
} from '../utils/format'
|
||||
|
||||
const SPORTS = ['all', 'running', 'cycling', 'swimming', 'hiking', 'walking']
|
||||
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||
|
||||
export default function ActivitiesPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [sport, setSport] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const fromParam = searchParams.get('from')
|
||||
const toParam = searchParams.get('to')
|
||||
|
||||
const { data: activities, isLoading } = useQuery({
|
||||
queryKey: ['activities', sport, page],
|
||||
queryKey: ['activities', sport, page, fromParam, toParam],
|
||||
queryFn: () =>
|
||||
api.get('/activities/', {
|
||||
params: {
|
||||
sport_type: sport === 'all' ? undefined : sport,
|
||||
page,
|
||||
per_page: 20,
|
||||
from_date: fromParam ? new Date(fromParam).toISOString() : undefined,
|
||||
to_date: toParam ? new Date(toParam + 'T23:59:59').toISOString() : undefined,
|
||||
},
|
||||
}).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: ytdStats } = useQuery({
|
||||
queryKey: ['ytd-stats'],
|
||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||
})
|
||||
|
||||
const clearDateFilter = () => navigate('/activities')
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Link
|
||||
to="/upload"
|
||||
@@ -37,6 +52,28 @@ export default function ActivitiesPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* YTD stats */}
|
||||
{ytdStats && (
|
||||
<div className="flex gap-4 mb-4 text-sm">
|
||||
{ytdStats.running_km > 0 && (
|
||||
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
|
||||
)}
|
||||
{ytdStats.cycling_km > 0 && (
|
||||
<span className="text-orange-400">🚴 {ytdStats.cycling_km.toFixed(0)} km this year</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date filter chip */}
|
||||
{fromParam && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xs bg-blue-600/20 text-blue-300 border border-blue-500/30 px-3 py-1 rounded-full">
|
||||
Week of {format(new Date(fromParam), 'MMM d, yyyy')}
|
||||
</span>
|
||||
<button onClick={clearDateFilter} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">✕ Clear</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sport filter */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{SPORTS.map(s => (
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
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 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 HRZoneBar from '../components/activity/HRZoneBar'
|
||||
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 {
|
||||
formatDuration, formatDistance, formatPace, formatElevation,
|
||||
formatHeartRate, formatDateTime, sportIcon,
|
||||
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
||||
} from '../utils/format'
|
||||
|
||||
import { projectToTrack } from '../utils/track'
|
||||
|
||||
const METRICS = [
|
||||
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
||||
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
||||
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
|
||||
{ key: 'cadence', label: 'Cadence', unit: 'rpm', color: '#f97316' },
|
||||
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
|
||||
{ key: 'power', label: 'Power', unit: 'W', color: '#a855f7' },
|
||||
{ key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' },
|
||||
]
|
||||
@@ -25,6 +29,13 @@ export default function ActivityDetailPage() {
|
||||
const { id } = useParams()
|
||||
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
||||
const [hoveredDistance, setHoveredDistance] = useState(null)
|
||||
const [mapHeight, setMapHeight] = useState(420)
|
||||
const [mapType, setMapType] = useState('street')
|
||||
const [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({
|
||||
queryKey: ['activity', id],
|
||||
@@ -43,25 +54,71 @@ export default function ActivityDetailPage() {
|
||||
enabled: !!activity,
|
||||
})
|
||||
|
||||
const { data: actSegments } = useQuery({
|
||||
queryKey: ['activity-segments', id],
|
||||
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,
|
||||
})
|
||||
|
||||
const { data: routeBoard } = useQuery({
|
||||
queryKey: ['route-leaderboard', id],
|
||||
queryFn: () => api.get(`/activities/${id}/route-leaderboard`).then(r => r.data),
|
||||
enabled: !!activity?.named_route_id,
|
||||
})
|
||||
|
||||
const handleMapClick = ({ lat, lng }) => {
|
||||
if (!segCreate || !dataPoints) return
|
||||
const proj = projectToTrack(dataPoints, lat, lng)
|
||||
if (proj?.distance_m == null) return
|
||||
const dist = proj.distance_m
|
||||
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) => {
|
||||
setActiveMetrics(prev =>
|
||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">Loading activity…</div>
|
||||
</div>
|
||||
// Check which metrics have actual data
|
||||
const availableMetrics = useMemo(() => {
|
||||
if (!dataPoints?.length) return new Set()
|
||||
return new Set(
|
||||
METRICS
|
||||
.filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0))
|
||||
.map(m => m.key)
|
||||
)
|
||||
}, [dataPoints])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-full"><div className="text-gray-500">Loading activity…</div></div>
|
||||
}
|
||||
|
||||
if (!activity) return null
|
||||
|
||||
const speed = activity.avg_speed_ms
|
||||
const pace = formatPace(speed, activity.sport_type)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
@@ -75,50 +132,150 @@ export default function ActivityDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{/* Stats — all on one row */}
|
||||
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3">
|
||||
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
||||
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
|
||||
<StatCard label="Pace" value={pace} />
|
||||
<StatCard label="Elevation" value={`↑ ${formatElevation(activity.elevation_gain_m)}`} />
|
||||
<StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
|
||||
sub={activity.moving_time_s ? 'moving' : undefined} />
|
||||
{activity.moving_time_s != null && Math.abs(activity.moving_time_s - (activity.duration_s ?? 0)) >= 1 && (
|
||||
<StatCard label="Elapsed" value={formatDuration(activity.duration_s)} />
|
||||
)}
|
||||
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
|
||||
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
||||
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
||||
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||
</div>
|
||||
|
||||
{/* Secondary stats */}
|
||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
||||
<StatCard label="Avg Cadence" value={activity.avg_cadence ? `${Math.round(activity.avg_cadence)} rpm` : '--'} />
|
||||
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
|
||||
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
|
||||
<StatCard label="TSS" value={activity.training_stress_score ? Math.round(activity.training_stress_score) : '--'} />
|
||||
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
|
||||
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
||||
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800" style={{ height: 420 }}>
|
||||
<ActivityMap
|
||||
polyline={activity.polyline}
|
||||
dataPoints={dataPoints}
|
||||
hoveredDistance={hoveredDistance}
|
||||
sportType={activity.sport_type}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HR Zones */}
|
||||
{activity.hr_zones && Object.keys(activity.hr_zones).length > 0 && (
|
||||
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
|
||||
<HRZoneBar zones={activity.hr_zones} />
|
||||
<HRZoneBar zones={activity.hr_zones} totalSeconds={activity.moving_time_s ?? activity.duration_s} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metric selector */}
|
||||
{/* Map and activity timeline side by side, each ~half width on large screens */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
{/* 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">
|
||||
{/* Map toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Map style:</span>
|
||||
{['dark', 'street', 'satellite'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setMapType(t)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
|
||||
mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</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 className="flex items-center gap-2">
|
||||
<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 => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setMapHeight(h)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
||||
mapHeight === h ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{h === 280 ? 'S' : h === 420 ? 'M' : 'L'}
|
||||
</button>
|
||||
))}
|
||||
</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 }}>
|
||||
<ActivityMap
|
||||
polyline={activity.polyline}
|
||||
dataPoints={dataPoints}
|
||||
hoveredDistance={hoveredDistance}
|
||||
sportType={activity.sport_type}
|
||||
mapType={mapType}
|
||||
colorMode={colorMode}
|
||||
onMapClick={segCreate ? handleMapClick : undefined}
|
||||
/>
|
||||
</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 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 */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{METRICS.map(({ key, label, color }) => (
|
||||
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => toggleMetric(key)}
|
||||
@@ -134,23 +291,42 @@ export default function ActivityDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dataPoints && (
|
||||
{dataPoints && dataPoints.length > 0 ? (
|
||||
<MetricTimeline
|
||||
dataPoints={dataPoints}
|
||||
activeMetrics={activeMetrics}
|
||||
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
|
||||
metrics={METRICS}
|
||||
onHoverDistance={setHoveredDistance}
|
||||
sportType={activity.sport_type}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Laps */}
|
||||
{laps && laps.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
||||
<LapTable laps={laps} sportType={activity.sport_type} />
|
||||
{/* Laps · Routes · Segments — on one row, each shrinking to fit and
|
||||
expanding to fill the width when fewer are present. */}
|
||||
{((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 && (
|
||||
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
||||
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
|
||||
</div>
|
||||
)}
|
||||
{routeBoard && routeBoard.top?.length > 0 && (
|
||||
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Route — Top 10 Times</h3>
|
||||
<RouteLeaderboard data={routeBoard} />
|
||||
</div>
|
||||
)}
|
||||
{actSegments && actSegments.length > 0 && (
|
||||
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
|
||||
<SegmentsPanel segments={actSegments} activityId={Number(id)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,197 +1,652 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { format, subDays, startOfWeek } from 'date-fns'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
BarChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import GridLayout, { WidthProvider } from 'react-grid-layout'
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
import 'react-resizable/css/styles.css'
|
||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import ActivityMap from '../components/activity/ActivityMap'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||
formatDate, sportIcon, formatSleep,
|
||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||
formatDate, sportIcon, sportColor, formatSleep,
|
||||
} from '../utils/format'
|
||||
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
||||
|
||||
function WeeklyChart({ activities }) {
|
||||
if (!activities?.length) return null
|
||||
const Grid = WidthProvider(GridLayout)
|
||||
|
||||
// Build last 8 weeks of distance data
|
||||
const weeks = {}
|
||||
activities.forEach(a => {
|
||||
const week = format(startOfWeek(new Date(a.start_time)), 'MMM d')
|
||||
if (!weeks[week]) weeks[week] = { week, km: 0, runs: 0 }
|
||||
weeks[week].km += (a.distance_m || 0) / 1000
|
||||
weeks[week].runs++
|
||||
})
|
||||
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, color: '#fff' }
|
||||
|
||||
const data = Object.values(weeks).slice(-8)
|
||||
const HRV_PALETTE = {
|
||||
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
||||
unbalanced: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
||||
low: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||
}
|
||||
|
||||
// Compact single-stat widgets. val(health, ytdStats) → display string.
|
||||
const STAT_DEFS = {
|
||||
stat_steps: { label: 'Steps today', accent: 'green', sub: 'goal 10,000', val: h => h.steps != null ? h.steps.toLocaleString() : '--' },
|
||||
stat_resting_hr: { label: 'Resting HR', accent: 'red', val: h => formatHeartRate(h.resting_hr) },
|
||||
stat_sleep: { label: 'Sleep', accent: 'default', val: h => formatSleep(h.sleep_duration_s) },
|
||||
stat_vo2max: { label: 'VO₂ max', accent: 'blue', val: h => h.vo2max != null ? h.vo2max.toFixed(1) : '--', sub: h => h.fitness_age != null ? `fitness age ${h.fitness_age}` : undefined },
|
||||
stat_hrv: { label: 'HRV status', accent: 'purple', val: h => h.hrv_nightly_avg != null ? `${Math.round(h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status || undefined },
|
||||
stat_running: { label: 'Running this year', accent: 'blue', val: (h, y) => y ? `${y.running_km.toFixed(0)} km` : '--' },
|
||||
stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y) => y ? `${y.cycling_km.toFixed(0)} km` : '--' },
|
||||
stat_stress: { label: 'Stress', accent: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' },
|
||||
stat_calories: { label: 'Active calories', accent: 'orange', val: h => h.active_calories != null ? Math.round(h.active_calories).toLocaleString() : '--' },
|
||||
stat_floors: { label: 'Floors climbed', accent: 'green', val: h => h.floors_climbed != null ? h.floors_climbed : '--' },
|
||||
}
|
||||
|
||||
// Full widget registry: size defaults + palette label. Stats inherit from STAT_DEFS.
|
||||
const WIDGETS = {
|
||||
...Object.fromEntries(Object.entries(STAT_DEFS).map(([id, d]) => [id, { label: d.label, w: 2, h: 1, minW: 1, minH: 1 }])),
|
||||
weekly: { label: 'Weekly distance', w: 6, h: 3, minW: 4, minH: 2 },
|
||||
bodyBattery: { label: 'Body Battery', w: 4, h: 3, minW: 3, minH: 2 },
|
||||
vo2maxTrend: { label: 'VO₂ max trend', w: 3, h: 3, minW: 2, minH: 2 },
|
||||
sleepDetail: { label: 'Sleep stages', w: 5, h: 3, minW: 3, minH: 2 },
|
||||
weight: { label: 'Weight trend', w: 3, h: 3, minW: 2, minH: 2 },
|
||||
featured: { label: 'Latest activity', w: 8, h: 5, minW: 4, minH: 3 },
|
||||
recent: { label: 'Recent activities', w: 4, h: 5, minW: 3, minH: 3 },
|
||||
prs: { label: 'Running PRs', w: 12, h: 2, minW: 4, minH: 2 },
|
||||
}
|
||||
|
||||
// Default arrangement (used for new users and to migrate pre-redesign layouts).
|
||||
const DEFAULT_LAYOUT = [
|
||||
{ i: 'stat_steps', x: 0, y: 0, w: 2, h: 1 },
|
||||
{ i: 'stat_resting_hr', x: 2, y: 0, w: 2, h: 1 },
|
||||
{ i: 'stat_sleep', x: 4, y: 0, w: 2, h: 1 },
|
||||
{ i: 'stat_vo2max', x: 6, y: 0, w: 2, h: 1 },
|
||||
{ i: 'stat_hrv', x: 8, y: 0, w: 2, h: 1 },
|
||||
{ i: 'stat_running', x: 10, y: 0, w: 2, h: 1 },
|
||||
{ i: 'weekly', x: 0, y: 1, w: 6, h: 3 },
|
||||
{ i: 'bodyBattery', x: 6, y: 1, w: 4, h: 3 },
|
||||
{ i: 'featured', x: 0, y: 4, w: 8, h: 5 },
|
||||
{ i: 'recent', x: 8, y: 4, w: 4, h: 5 },
|
||||
{ i: 'prs', x: 0, y: 9, w: 12, h: 2 },
|
||||
]
|
||||
|
||||
const attachMins = (lay) =>
|
||||
lay.filter(l => WIDGETS[l.i]).map(l => ({ ...l, minW: WIDGETS[l.i].minW, minH: WIDGETS[l.i].minH }))
|
||||
|
||||
function buildLayout(saved) {
|
||||
const known = (saved || []).filter(l => WIDGETS[l.i])
|
||||
// Migrate old layouts (no stat_* widgets) or empty/missing to the new default.
|
||||
const hasStats = known.some(l => l.i.startsWith('stat_'))
|
||||
return attachMins(known.length && hasStats ? known : DEFAULT_LAYOUT)
|
||||
}
|
||||
|
||||
// ── Reusable card shell ──────────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, viewHref, children, className = '' }) {
|
||||
return (
|
||||
<div className={`bg-gray-900 rounded-xl border border-gray-800 p-4 h-full flex flex-col ${className}`}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-300">{title}</h3>
|
||||
{viewHref && <Link to={viewHref} className="text-xs text-blue-400 hover:underline">View →</Link>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }) {
|
||||
return (
|
||||
<div className="bg-gray-900 px-4 py-3 flex flex-col justify-center">
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-lg font-semibold text-white">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chart widgets ────────────────────────────────────────────────────────────
|
||||
|
||||
function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd }) {
|
||||
const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ t: ts, level }))
|
||||
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
|
||||
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
|
||||
const data = raw.map((d, i) => ({
|
||||
...d,
|
||||
type: inferBBType(d.t, d.level, i > 0 ? raw[i - 1].level : null, sleepStartMs, sleepEndMs),
|
||||
}))
|
||||
const charged = bb?.charged, drained = bb?.drained, end_level = bb?.end_level
|
||||
const peak = data.length ? Math.max(...data.map(d => d.level)) : end_level
|
||||
const hasGraph = data.length >= 2
|
||||
const presentTypes = [...new Set(data.map(d => d.type))]
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||
tickFormatter={v => `${v.toFixed(0)}`} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||
formatter={(v, name) => [`${v.toFixed(1)} km`, 'Distance']}
|
||||
/>
|
||||
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
<Card title="Body Battery" viewHref="/health">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
{peak != null && <span className="text-3xl font-bold" style={{ color: bbLevelColor(peak) }}>{Math.round(peak)}</span>}
|
||||
{charged != null && <span className="text-sm font-semibold text-green-400">+{charged}</span>}
|
||||
{drained != null && <span className="text-sm font-semibold text-orange-400">-{drained}</span>}
|
||||
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
|
||||
</div>
|
||||
{hasGraph ? (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 mt-2">
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={80}>
|
||||
<BarChart data={data} margin={{ top: 2, right: 4, bottom: 0, left: 0 }} barCategoryGap={0}>
|
||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
interval={Math.max(1, Math.floor(data.length / 6))} />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
width={26} ticks={[0, 50, 100]} />
|
||||
<Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)}%`, 'Battery']} />
|
||||
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
||||
{data.map((d, i) => <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{presentTypes.map(type => (
|
||||
<div key={type} className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
|
||||
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 mt-3">No body battery data today</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Sparkline({ data, dataKey, color, gradId, fmt }) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={60}>
|
||||
<AreaChart data={data} margin={{ top: 4, right: 2, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={gradId} 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>
|
||||
<YAxis domain={['dataMin - 1', 'dataMax + 1']} hide />
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d')}
|
||||
formatter={v => [fmt ? fmt(v) : v, '']} />
|
||||
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2} fill={`url(#${gradId})`}
|
||||
dot={false} connectNulls isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: recentActivities } = useQuery({
|
||||
queryKey: ['activities-recent'],
|
||||
queryFn: () => api.get('/activities/', { params: { per_page: 10 } }).then(r => r.data),
|
||||
})
|
||||
function Vo2MaxTrend({ health, recentHealth }) {
|
||||
const series = useMemo(
|
||||
() => [...(recentHealth || [])].filter(d => d.vo2max != null)
|
||||
.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
.map(d => ({ date: d.date, v: d.vo2max })),
|
||||
[recentHealth],
|
||||
)
|
||||
return (
|
||||
<Card title="VO₂ Max" viewHref="/health">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-blue-400">{health.vo2max != null ? health.vo2max.toFixed(1) : '--'}</span>
|
||||
<span className="text-xs text-gray-500">ml/kg/min</span>
|
||||
</div>
|
||||
{health.fitness_age != null && <p className="text-xs text-gray-500 mt-0.5">Fitness age {health.fitness_age}</p>}
|
||||
<div className="flex-1 min-h-0 mt-2">
|
||||
{series.length >= 2
|
||||
? <Sparkline data={series} dataKey="v" color="#3b82f6" gradId="grad-dash-vo2" fmt={v => v.toFixed(1)} />
|
||||
: <p className="text-xs text-gray-600">Not enough history</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const { data: allActivities } = useQuery({
|
||||
queryKey: ['activities-all-chart'],
|
||||
queryFn: () =>
|
||||
api.get('/activities/', {
|
||||
params: {
|
||||
per_page: 100,
|
||||
from_date: subDays(new Date(), 60).toISOString(),
|
||||
},
|
||||
}).then(r => r.data),
|
||||
})
|
||||
function WeightMini({ recentHealth }) {
|
||||
const series = useMemo(
|
||||
() => [...(recentHealth || [])].filter(d => d.weight_kg != null)
|
||||
.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
.map(d => ({ date: d.date, w: +d.weight_kg.toFixed(2) })),
|
||||
[recentHealth],
|
||||
)
|
||||
const latest = series.length ? series[series.length - 1].w : null
|
||||
return (
|
||||
<Card title="Weight" viewHref="/health">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-emerald-300">{latest != null ? latest.toFixed(1) : '--'}</span>
|
||||
<span className="text-xs text-gray-500">kg</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 mt-2">
|
||||
{series.length >= 2
|
||||
? <Sparkline data={series} dataKey="w" color="#34d399" gradId="grad-dash-weight" fmt={v => `${v.toFixed(1)} kg`} />
|
||||
: <p className="text-xs text-gray-600">Not enough history</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const { data: healthSummary } = useQuery({
|
||||
queryKey: ['health-summary'],
|
||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||
})
|
||||
const SLEEP_STAGES = [
|
||||
{ key: 'sleep_deep_s', label: 'Deep', color: '#3b82f6' },
|
||||
{ key: 'sleep_rem_s', label: 'REM', color: '#8b5cf6' },
|
||||
{ key: 'sleep_light_s', label: 'Light', color: '#60a5fa' },
|
||||
{ key: 'sleep_awake_s', label: 'Awake', color: '#6b7280' },
|
||||
]
|
||||
|
||||
const { data: records } = useQuery({
|
||||
queryKey: ['records-running'],
|
||||
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
|
||||
})
|
||||
function SleepDetail({ health }) {
|
||||
const total = SLEEP_STAGES.reduce((s, st) => s + (health[st.key] || 0), 0)
|
||||
return (
|
||||
<Card title="Sleep" viewHref="/health">
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
<span className="text-3xl font-bold text-indigo-300">{formatSleep(health.sleep_duration_s)}</span>
|
||||
{health.sleep_score != null && (
|
||||
<span className="text-sm text-gray-400">score <span className="text-white font-semibold">{Math.round(health.sleep_score)}</span></span>
|
||||
)}
|
||||
</div>
|
||||
{total > 0 ? (
|
||||
<>
|
||||
<div className="flex h-3 rounded-full overflow-hidden gap-0.5 mt-3">
|
||||
{SLEEP_STAGES.map(st => {
|
||||
const pct = ((health[st.key] || 0) / total) * 100
|
||||
if (pct < 0.5) return null
|
||||
return <div key={st.key} style={{ width: `${pct}%`, backgroundColor: st.color }} title={`${st.label}: ${formatSleep(health[st.key])}`} />
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{SLEEP_STAGES.map(st => (health[st.key] ? (
|
||||
<div key={st.key} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: st.color }} />
|
||||
<span className="text-xs text-gray-400">{st.label}</span>
|
||||
<span className="text-xs text-white">{formatSleep(health[st.key])}</span>
|
||||
</div>
|
||||
) : null))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 mt-3">No sleep stages for last night</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const latest = healthSummary?.latest
|
||||
const totalActivities = recentActivities?.length ?? 0
|
||||
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
|
||||
const sportLabel = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Other')
|
||||
|
||||
function WeeklyChart({ activities }) {
|
||||
const navigate = useNavigate()
|
||||
const { data, sports } = useMemo(() => {
|
||||
if (!activities?.length) return { data: [], sports: [] }
|
||||
// Sports present, ordered by total distance (largest stacks at the bottom).
|
||||
const totals = {}
|
||||
for (const a of activities) totals[a.sport_type] = (totals[a.sport_type] || 0) + (a.distance_m || 0)
|
||||
const sports = Object.keys(totals).sort((x, y) => totals[y] - totals[x])
|
||||
const now = new Date()
|
||||
const weeks = eachWeekOfInterval({ start: subWeeks(startOfWeek(now), 7), end: startOfWeek(now) })
|
||||
const data = weeks.map(weekStart => {
|
||||
const weekEnd = addDays(weekStart, 7)
|
||||
const row = { week: format(weekStart, 'MMM d'), weekStartISO: format(weekStart, 'yyyy-MM-dd'), weekEndISO: format(weekEnd, 'yyyy-MM-dd') }
|
||||
for (const s of sports) row[s] = 0
|
||||
for (const a of activities) {
|
||||
const t = new Date(a.start_time)
|
||||
if (t >= weekStart && t < weekEnd) row[a.sport_type] += (a.distance_m || 0) / 1000
|
||||
}
|
||||
for (const s of sports) row[s] = +row[s].toFixed(2)
|
||||
return row
|
||||
})
|
||||
return { data, sports }
|
||||
}, [activities])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<Link
|
||||
to="/upload"
|
||||
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
+ Import data
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Top stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Activities (10)" value={totalActivities} />
|
||||
<StatCard label="Distance (10)" value={formatDistance(totalDistance)} accent="blue" />
|
||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Weekly distance chart */}
|
||||
<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>
|
||||
<WeeklyChart activities={allActivities} />
|
||||
</div>
|
||||
|
||||
{/* Health snapshot */}
|
||||
<div className="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>
|
||||
{latest ? (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">HRV</span>
|
||||
<span className="text-white">{latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Sleep score</span>
|
||||
<span className="text-white">{latest.sleep_score ? Math.round(latest.sleep_score) : '--'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Steps</span>
|
||||
<span className="text-white">{latest.steps?.toLocaleString() ?? '--'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">VO2 Max</span>
|
||||
<span className="text-white">{latest.vo2max ? latest.vo2max.toFixed(1) : '--'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Stress</span>
|
||||
<span className="text-white">{latest.avg_stress ? Math.round(latest.avg_stress) : '--'}</span>
|
||||
</div>
|
||||
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">
|
||||
View full health dashboard →
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent activities */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Recent activities</h3>
|
||||
<Link to="/activities" className="text-xs text-blue-400 hover:underline">View all →</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{recentActivities?.slice(0, 5).map(activity => (
|
||||
<Link
|
||||
key={activity.id}
|
||||
to={`/activities/${activity.id}`}
|
||||
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors"
|
||||
>
|
||||
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||
</div>
|
||||
<div className="flex gap-4 text-sm text-right">
|
||||
<div>
|
||||
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p>
|
||||
<p className="text-xs text-gray-600">dist</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-200">{formatDuration(activity.duration_s)}</p>
|
||||
<p className="text-xs text-gray-600">time</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
|
||||
<p className="text-xs text-gray-600">HR</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{!recentActivities?.length && (
|
||||
<p className="text-gray-600 text-sm text-center py-8">
|
||||
No activities yet — <Link to="/upload" className="text-blue-400 hover:underline">import some data</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PRs snapshot */}
|
||||
{records?.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Running PRs</h3>
|
||||
<Link to="/records" className="text-xs text-blue-400 hover:underline">View all →</Link>
|
||||
<Card title="Weekly distance (km)">
|
||||
{data.length ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={100}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}
|
||||
onClick={e => { const p = e?.activePayload?.[0]?.payload; if (p) navigate(`/activities?from=${p.weekStartISO}&to=${p.weekEndISO}`) }}
|
||||
style={{ cursor: 'pointer' }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28} tickFormatter={v => `${v.toFixed(0)}`} />
|
||||
<Tooltip contentStyle={tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.06)' }}
|
||||
formatter={(v, name) => [`${(+v).toFixed(1)} km`, sportLabel(name)]} />
|
||||
{sports.map((s, i) => (
|
||||
<Bar key={s} dataKey={s} stackId="dist" fill={sportColor(s)} isAnimationActive={false}
|
||||
radius={i === sports.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{records.slice(0, 5).map(rec => (
|
||||
<div key={rec.id} className="bg-gray-800/60 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">{rec.distance_label}</p>
|
||||
<p className="font-mono font-semibold text-yellow-400">{formatDuration(rec.duration_s)}</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{sports.map(s => (
|
||||
<div key={s} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: sportColor(s) }} />
|
||||
<span className="text-xs text-gray-400">{sportLabel(s)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-600 text-sm">No activities yet</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FeaturedActivity({ activity, segments }) {
|
||||
if (!activity) return (
|
||||
<Card title="Latest activity"><div className="flex items-center justify-center h-full text-gray-600 text-sm">No activities yet</div></Card>
|
||||
)
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xl">{sportIcon(activity.sport_type)}</span>
|
||||
<div className="min-w-0">
|
||||
<Link to={`/activities/${activity.id}`} className="text-sm font-semibold text-white hover:text-blue-400 transition-colors truncate block">{activity.name}</Link>
|
||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/activities/${activity.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 flex-1 min-h-0">
|
||||
<div className="lg:col-span-2 min-h-[180px] bg-gray-950">
|
||||
{activity.polyline
|
||||
? <ActivityMap polyline={activity.polyline} sportType={activity.sport_type} colorMode="solid" mapType="dark" />
|
||||
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
|
||||
<Stat label="Distance" value={formatDistance(activity.distance_m)} />
|
||||
<Stat label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
||||
<Stat label="Moving time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)} />
|
||||
<Stat label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||
</div>
|
||||
</div>
|
||||
{segments?.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/${activity.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">
|
||||
{segments.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>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentActivities({ activities }) {
|
||||
return (
|
||||
<Card title="Recent activities" viewHref="/activities">
|
||||
<div className="space-y-2 overflow-auto h-full">
|
||||
{activities?.slice(0, 6).map(activity => (
|
||||
<Link key={activity.id} to={`/activities/${activity.id}`}
|
||||
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p>
|
||||
<p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{!activities?.length && (
|
||||
<p className="text-gray-600 text-sm text-center py-8">No activities yet — <Link to="/upload" className="text-blue-400 hover:underline">import some data</Link></p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const DASH_PR_LABELS = ['1k', '1 mile', '5k', '10k']
|
||||
|
||||
function RunningPRs({ records }) {
|
||||
const byLabel = Object.fromEntries((records || []).map(r => [r.distance_label, r]))
|
||||
return (
|
||||
<Card title="Running PRs" viewHref="/records">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{DASH_PR_LABELS.map(label => {
|
||||
const rec = byLabel[label]
|
||||
return (
|
||||
<div key={label} className="bg-gray-800/60 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||
<p className="font-mono font-semibold text-yellow-400">{rec ? formatDuration(rec.duration_s) : '--'}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: recentActivities } = useQuery({
|
||||
queryKey: ['activities-recent'],
|
||||
queryFn: () => api.get('/activities/', { params: { per_page: 10 } }).then(r => r.data),
|
||||
})
|
||||
const { data: allActivities } = useQuery({
|
||||
queryKey: ['activities-all-chart'],
|
||||
queryFn: () => api.get('/activities/', { params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() } }).then(r => r.data),
|
||||
})
|
||||
const { data: recentHealth } = useQuery({
|
||||
queryKey: ['health-metrics', 'dash'],
|
||||
queryFn: () => api.get('/health-metrics/', { params: { limit: 365 } }).then(r => r.data),
|
||||
})
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: () => api.get('/profile/').then(r => r.data),
|
||||
})
|
||||
|
||||
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
|
||||
const latest = rows[0] || {}
|
||||
return {
|
||||
date: rows[0]?.date ? rows[0].date.slice(0, 10) : null,
|
||||
resting_hr: pick('resting_hr'),
|
||||
sleep_duration_s: pick('sleep_duration_s'),
|
||||
sleep_start: latest.sleep_start ?? null,
|
||||
sleep_end: latest.sleep_end ?? null,
|
||||
sleep_deep_s: latest.sleep_deep_s ?? null,
|
||||
sleep_rem_s: latest.sleep_rem_s ?? null,
|
||||
sleep_light_s: latest.sleep_light_s ?? null,
|
||||
sleep_awake_s: latest.sleep_awake_s ?? null,
|
||||
sleep_score: pick('sleep_score'),
|
||||
hrv_nightly_avg: pick('hrv_nightly_avg'),
|
||||
hrv_status: pick('hrv_status'),
|
||||
steps: pick('steps'),
|
||||
vo2max: pick('vo2max'),
|
||||
fitness_age: pick('fitness_age'),
|
||||
avg_stress: pick('avg_stress'),
|
||||
active_calories: pick('active_calories'),
|
||||
floors_climbed: pick('floors_climbed'),
|
||||
}
|
||||
}, [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({
|
||||
queryKey: ['records-running'],
|
||||
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 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,
|
||||
})
|
||||
|
||||
// ── Layout state ──────────────────────────────────────────────────────────
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [layout, setLayout] = useState(() => buildLayout(null))
|
||||
const saveTimer = useRef(null)
|
||||
const loadedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (profile && !loadedRef.current) {
|
||||
loadedRef.current = true
|
||||
setLayout(buildLayout(profile.dashboard_layout))
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
const qc = useQueryClient()
|
||||
const stripLayout = (lay) => lay.map(({ i, x, y, w, h }) => ({ i, x, y, w, h }))
|
||||
const saveLayout = useMutation({
|
||||
mutationFn: (lay) => api.put('/profile/dashboard-layout', { layout: stripLayout(lay) }),
|
||||
// Keep the cached profile in sync so re-mounting the page doesn't revert the layout.
|
||||
onSuccess: (_d, lay) => qc.setQueryData(['profile'], p => (p ? { ...p, dashboard_layout: stripLayout(lay) } : p)),
|
||||
})
|
||||
|
||||
const persist = (lay) => { clearTimeout(saveTimer.current); saveLayout.mutate(lay) }
|
||||
|
||||
const handleLayoutChange = (next) => {
|
||||
const withMins = attachMins(next)
|
||||
setLayout(withMins)
|
||||
if (editMode) {
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => saveLayout.mutate(withMins), 700)
|
||||
}
|
||||
}
|
||||
|
||||
const addWidget = (id) => {
|
||||
if (layout.some(l => l.i === id)) { setAddOpen(false); return }
|
||||
const maxY = layout.reduce((m, l) => Math.max(m, l.y + l.h), 0)
|
||||
const def = WIDGETS[id]
|
||||
const next = attachMins([...layout, { i: id, x: 0, y: maxY, w: def.w, h: def.h }])
|
||||
setLayout(next); persist(next); setAddOpen(false)
|
||||
}
|
||||
const removeWidget = (id) => { const next = layout.filter(l => l.i !== id); setLayout(next); persist(next) }
|
||||
|
||||
const finishEditing = () => { persist(layout); setEditMode(false); setAddOpen(false) }
|
||||
const resetLayout = () => { const def = attachMins(DEFAULT_LAYOUT); setLayout(def); persist(def) }
|
||||
|
||||
const renderWidget = (id) => {
|
||||
if (STAT_DEFS[id]) {
|
||||
const d = STAT_DEFS[id]
|
||||
return <StatCard label={d.label} accent={d.accent} value={d.val(health, ytdStats)}
|
||||
sub={typeof d.sub === 'function' ? d.sub(health) : d.sub} />
|
||||
}
|
||||
switch (id) {
|
||||
case 'weekly': return <WeeklyChart activities={allActivities} />
|
||||
case 'bodyBattery': return <BodyBatteryToday bb={intraday?.body_battery} hires={intraday?.body_battery_hires} sleepStart={health.sleep_start} sleepEnd={health.sleep_end} />
|
||||
case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} />
|
||||
case 'sleepDetail': return <SleepDetail health={health} />
|
||||
case 'weight': return <WeightMini recentHealth={recentHealth} />
|
||||
case 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
|
||||
case 'recent': return <RecentActivities activities={recentActivities} />
|
||||
case 'prs': return <RunningPRs records={records} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
const presentIds = new Set(layout.map(l => l.i))
|
||||
const available = Object.keys(WIDGETS).filter(id => !presentIds.has(id))
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{editMode && (
|
||||
<div className="relative">
|
||||
<button onClick={() => setAddOpen(o => !o)}
|
||||
className="text-sm font-medium px-3 py-1.5 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-200 transition-colors">
|
||||
+ Add widget
|
||||
</button>
|
||||
{addOpen && (
|
||||
<div className="absolute right-0 mt-1 w-56 max-h-80 overflow-auto bg-gray-900 border border-gray-700 rounded-lg shadow-xl z-50 py-1">
|
||||
{available.length === 0
|
||||
? <p className="text-xs text-gray-500 px-3 py-2">All widgets are on the dashboard</p>
|
||||
: available.map(id => (
|
||||
<button key={id} onClick={() => addWidget(id)}
|
||||
className="block w-full text-left text-sm text-gray-300 hover:bg-gray-800 px-3 py-1.5 transition-colors">
|
||||
{WIDGETS[id].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{editMode && (
|
||||
<button onClick={resetLayout} className="text-xs text-gray-400 hover:text-white transition-colors">Reset layout</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => (editMode ? finishEditing() : setEditMode(true))}
|
||||
className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${
|
||||
editMode ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-gray-800 hover:bg-gray-700 text-gray-200'
|
||||
}`}>
|
||||
{editMode ? '✓ Done' : '✎ Edit dashboard'}
|
||||
</button>
|
||||
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editMode && (
|
||||
<p className="text-xs text-gray-500 mb-3">Drag to move, drag a corner to resize, or remove a widget with ✕. Add widgets from the menu. Changes save automatically.</p>
|
||||
)}
|
||||
|
||||
<Grid
|
||||
className="layout"
|
||||
layout={layout}
|
||||
cols={12}
|
||||
rowHeight={80}
|
||||
margin={[16, 16]}
|
||||
isDraggable={editMode}
|
||||
isResizable={editMode}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
compactType="vertical"
|
||||
draggableCancel=".widget-delete"
|
||||
>
|
||||
{layout.filter(l => WIDGETS[l.i]).map(l => (
|
||||
<div key={l.i} className={`rounded-xl relative ${editMode ? 'ring-2 ring-blue-500/40 cursor-move' : ''}`}>
|
||||
{editMode && (
|
||||
<button onClick={() => removeWidget(l.i)}
|
||||
className="widget-delete absolute -top-2 -right-2 z-20 w-6 h-6 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white text-xs shadow-lg"
|
||||
title="Remove widget">✕</button>
|
||||
)}
|
||||
<div className={`h-full ${editMode ? 'pointer-events-none select-none' : ''}`}>
|
||||
{renderWidget(l.i)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+1100
-208
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,19 @@ import { useAuthStore } from '../hooks/useAuth'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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() {
|
||||
const [username, setUsername] = 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 navigate = useNavigate()
|
||||
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { useAuthStore } from '../hooks/useAuth'
|
||||
import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync'
|
||||
|
||||
// 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 }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-gray-300">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-gray-600 mt-1">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
|
||||
return (
|
||||
<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
)
|
||||
}
|
||||
|
||||
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button onClick={onClick} disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
{loading ? 'Saving…' : label}
|
||||
</button>
|
||||
{saved && <span className="text-green-400 text-sm">✓ Saved</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const qc = useQueryClient()
|
||||
const { user, fetchUser } = useAuthStore()
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
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({
|
||||
queryKey: ['pocketid-config'],
|
||||
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
|
||||
enabled: !!user?.is_admin,
|
||||
})
|
||||
|
||||
const { data: healthSummary } = useQuery({
|
||||
queryKey: ['health-summary'],
|
||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||
})
|
||||
|
||||
// HR / measurements form
|
||||
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '', goal_weight_kg: '' })
|
||||
const [hrSaved, setHrSaved] = useState(false)
|
||||
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
|
||||
const maxHrChangedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (profile) setHrForm({
|
||||
max_heart_rate: profile.max_heart_rate || '',
|
||||
birth_year: profile.birth_year || '',
|
||||
height_cm: profile.height_cm || '',
|
||||
biological_sex: profile.biological_sex || '',
|
||||
goal_weight_kg: profile.goal_weight_kg || '',
|
||||
})
|
||||
}, [profile])
|
||||
|
||||
const updateProfile = useMutation({
|
||||
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['profile'] })
|
||||
setHrSaved(true)
|
||||
setTimeout(() => setHrSaved(false), 3000)
|
||||
if (maxHrChangedRef.current) {
|
||||
setHrZoneRecalc(true)
|
||||
setTimeout(() => setHrZoneRecalc(false), 6000)
|
||||
maxHrChangedRef.current = false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Password change
|
||||
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
|
||||
const [pwError, setPwError] = useState('')
|
||||
const [pwSaved, setPwSaved] = useState(false)
|
||||
const changePassword = useMutation({
|
||||
mutationFn: data => api.post('/profile/change-password', data).then(r => r.data),
|
||||
onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) },
|
||||
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
|
||||
})
|
||||
|
||||
// Garmin Connect sync
|
||||
const { data: garminConfig, refetch: refetchGarmin } = useQuery({
|
||||
queryKey: ['garmin-config'],
|
||||
queryFn: () => api.get('/garmin-sync/config').then(r => r.data),
|
||||
})
|
||||
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 [gcError, setGcError] = useState('')
|
||||
const { inProgress: gcSyncing, status: syncStatus, trigger: triggerSync, cancel: cancelSync } = useSyncStore()
|
||||
const gcFormLoaded = useRef(false)
|
||||
useEffect(() => {
|
||||
if (garminConfig?.connected && !gcFormLoaded.current) {
|
||||
gcFormLoaded.current = true
|
||||
setGcForm(f => ({
|
||||
...f,
|
||||
email: garminConfig.email || '',
|
||||
sync_enabled: garminConfig.sync_enabled,
|
||||
sync_activities: garminConfig.sync_activities,
|
||||
sync_wellness: garminConfig.sync_wellness,
|
||||
sync_lookback_days: String(garminConfig.sync_lookback_days ?? 30),
|
||||
}))
|
||||
} else if (!garminConfig?.connected) {
|
||||
gcFormLoaded.current = false
|
||||
}
|
||||
}, [garminConfig])
|
||||
const saveGarmin = useMutation({
|
||||
mutationFn: data => api.put('/garmin-sync/config', data).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
refetchGarmin()
|
||||
setGcSaved(true)
|
||||
setGcError('')
|
||||
setGcForm(f => ({ ...f, password: '' }))
|
||||
setTimeout(() => setGcSaved(false), 3000)
|
||||
},
|
||||
onError: e => setGcError(e.response?.data?.detail || 'Failed to save'),
|
||||
})
|
||||
const deleteGarmin = useMutation({
|
||||
mutationFn: () => api.delete('/garmin-sync/config'),
|
||||
onSuccess: () => {
|
||||
refetchGarmin()
|
||||
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
|
||||
},
|
||||
})
|
||||
// PocketID config
|
||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
|
||||
const [pidSaved, setPidSaved] = useState(false)
|
||||
useEffect(() => {
|
||||
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '', allowed_group: pocketidConfig.allowed_group || '' })
|
||||
}, [pocketidConfig])
|
||||
const savePocketID = useMutation({
|
||||
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
|
||||
})
|
||||
|
||||
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
|
||||
|
||||
{/* HR & Measurements */}
|
||||
<Section title="Heart Rate & Measurements">
|
||||
<div className="bg-blue-950/30 border border-blue-900/40 rounded-lg p-3 text-xs text-gray-400">
|
||||
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
|
||||
{effectiveMaxHr && (
|
||||
<div className="mt-2 text-white">
|
||||
Effective max HR: <strong>{effectiveMaxHr} bpm</strong>
|
||||
{!profile?.max_heart_rate && ' (estimated from age)'}
|
||||
{' · '}Zones: Z1 <{Math.round(effectiveMaxHr * 0.6)}, Z2 {Math.round(effectiveMaxHr * 0.6)}–{Math.round(effectiveMaxHr * 0.7)}, Z3 {Math.round(effectiveMaxHr * 0.7)}–{Math.round(effectiveMaxHr * 0.8)}, Z4 {Math.round(effectiveMaxHr * 0.8)}–{Math.round(effectiveMaxHr * 0.9)}, Z5 >{Math.round(effectiveMaxHr * 0.9)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
|
||||
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
||||
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Birth year" hint="Used to estimate max HR if not set above">
|
||||
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
|
||||
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Height (cm)">
|
||||
<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 }))} />
|
||||
</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 className="grid grid-cols-2 gap-4 pt-3 border-t border-gray-800">
|
||||
<Field label="Goal weight (kg)" hint="Shown as a target line on the weight trend chart">
|
||||
<Input type="number" value={hrForm.goal_weight_kg} placeholder="e.g. 72" min={20} max={500}
|
||||
onChange={e => setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} />
|
||||
</Field>
|
||||
{healthSummary?.latest?.weight_kg && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Current weight (from Garmin)</p>
|
||||
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
const data = Object.fromEntries(
|
||||
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
|
||||
updateProfile.mutate(data)
|
||||
}}
|
||||
loading={updateProfile.isPending}
|
||||
saved={hrSaved}
|
||||
/>
|
||||
{hrZoneRecalc && (
|
||||
<p className="text-xs text-blue-400 mt-1">HR zones are being recalculated for your existing activities.</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Password change */}
|
||||
<Section title="Change Password">
|
||||
<div className="space-y-3">
|
||||
<Field label="Current password">
|
||||
<Input type="password" value={pwForm.current_password}
|
||||
onChange={e => { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} />
|
||||
</Field>
|
||||
<Field label="New password (min 8 characters)">
|
||||
<Input type="password" value={pwForm.new_password}
|
||||
onChange={e => setPwForm(f => ({ ...f, new_password: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Confirm new password">
|
||||
<Input type="password" value={pwForm.confirm}
|
||||
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} />
|
||||
</Field>
|
||||
{pwError && <p className="text-red-400 text-xs">{pwError}</p>}
|
||||
</div>
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return }
|
||||
changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password })
|
||||
}}
|
||||
loading={changePassword.isPending}
|
||||
saved={pwSaved}
|
||||
label="Change password"
|
||||
/>
|
||||
</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 */}
|
||||
<Section title="⌚ Garmin Connect Sync">
|
||||
<p className="text-xs text-gray-500">
|
||||
Connect your Garmin account to automatically import new activities and wellness data
|
||||
{' '}{formatSyncInterval(garminConfig?.sync_interval_minutes)}. Credentials are encrypted at rest.
|
||||
</p>
|
||||
|
||||
{garminConfig?.connected && (
|
||||
<div className="flex items-center justify-between bg-green-900/20 border border-green-800/40 rounded-lg px-3 py-2 text-xs">
|
||||
<span className="text-green-400">✓ Connected as {garminConfig.email}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{garminConfig.last_sync_at && (
|
||||
<span className="text-gray-500">
|
||||
Last sync: {new Date(garminConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
{garminConfig.last_sync_status && (
|
||||
<span className={garminConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : garminConfig.last_sync_status.startsWith('Auth') ? 'text-red-400' : 'text-yellow-400'}>
|
||||
{garminConfig.last_sync_status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Field label="Garmin Connect email">
|
||||
<Input value={gcForm.email} placeholder="you@example.com"
|
||||
onChange={e => setGcForm(f => ({ ...f, email: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label={garminConfig?.connected ? 'Password (leave blank to keep existing)' : 'Password'}>
|
||||
<Input type="password" value={gcForm.password} placeholder="••••••••"
|
||||
onChange={e => setGcForm(f => ({ ...f, password: e.target.value }))} />
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-1">
|
||||
{[
|
||||
['sync_enabled', `Enable automatic sync (${formatSyncInterval(garminConfig?.sync_interval_minutes)})`],
|
||||
['sync_activities', 'Sync activities (FIT download)'],
|
||||
['sync_wellness', 'Sync wellness data'],
|
||||
].map(([key, label]) => (
|
||||
<label key={key} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={gcForm[key]}
|
||||
onChange={e => setGcForm(f => ({ ...f, [key]: e.target.checked }))}
|
||||
className="w-4 h-4 accent-blue-500" />
|
||||
<span className="text-sm text-gray-300">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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}
|
||||
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
|
||||
{(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && (
|
||||
<p className="text-yellow-400 text-xs mt-1">Warning: syncing more than 365 days at once may take a long time and could trigger Garmin rate limits.</p>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{gcError && <p className="text-red-400 text-xs">{gcError}</p>}
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap pt-1">
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
if (!garminConfig?.connected && !gcForm.password) {
|
||||
setGcError('Password is required for first-time setup')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
...gcForm,
|
||||
sync_lookback_days: parseInt(gcForm.sync_lookback_days, 10) || 30,
|
||||
}
|
||||
if (!payload.password) delete payload.password
|
||||
saveGarmin.mutate(payload)
|
||||
}}
|
||||
loading={saveGarmin.isPending}
|
||||
saved={gcSaved}
|
||||
label={garminConfig?.connected ? 'Update' : 'Connect'}
|
||||
/>
|
||||
{garminConfig?.connected && (
|
||||
<>
|
||||
<button
|
||||
onClick={triggerSync}
|
||||
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">
|
||||
{gcSyncing ? 'Syncing…' : '↻ Sync now'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Remove Garmin Connect credentials?')) deleteGarmin.mutate() }}
|
||||
className="text-red-400 hover:text-red-300 text-sm transition-colors">
|
||||
Disconnect
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gcSyncing && (() => {
|
||||
const status = syncStatus || ''
|
||||
const pct = syncProgressPct(status)
|
||||
const phase = syncPhase(status)
|
||||
return (
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{[['Connect', 0], ['Activities', 1], ['Wellness', 2]].map(([label, idx]) => (
|
||||
<span key={label} className={`flex items-center gap-1 ${phase >= idx ? 'text-blue-400' : 'text-gray-600'}`}>
|
||||
{idx > 0 && <span className="text-gray-700">›</span>}
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 flex-1 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
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>
|
||||
<p className="text-xs text-blue-400">
|
||||
{status || 'Starting sync…'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Section>
|
||||
|
||||
{/* PocketID — admin only */}
|
||||
{user?.is_admin && (
|
||||
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
||||
<p className="text-xs text-gray-500">
|
||||
Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Field label="PocketID issuer URL" hint="e.g. https://auth.yourdomain.com">
|
||||
<Input value={pidForm.issuer} placeholder="https://auth.example.com"
|
||||
onChange={e => setPidForm(f => ({ ...f, issuer: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Client ID">
|
||||
<Input value={pidForm.client_id} placeholder="milevault"
|
||||
onChange={e => setPidForm(f => ({ ...f, client_id: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Client secret" hint="Leave blank to keep existing secret">
|
||||
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
|
||||
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
|
||||
</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 && (
|
||||
<p className="text-xs text-green-400">✓ PocketID is currently active</p>
|
||||
)}
|
||||
</div>
|
||||
<SaveButton
|
||||
onClick={() => savePocketID.mutate(pidForm)}
|
||||
loading={savePocketID.isPending}
|
||||
saved={pidSaved}
|
||||
label="Save PocketID config"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, Fragment } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import { formatDuration, formatDate } from '../utils/format'
|
||||
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
|
||||
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
||||
|
||||
const SPORTS = ['running', 'cycling', 'swimming']
|
||||
const SPORTS = ['running', 'cycling']
|
||||
|
||||
const DISTANCE_ORDER = [
|
||||
'400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
|
||||
'Half marathon', 'Marathon', '50k', '100k',
|
||||
]
|
||||
|
||||
export default function RecordsPage() {
|
||||
const TABS = ['Distance PRs', 'Route Records', 'Segments']
|
||||
|
||||
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||
|
||||
function DistancePRs() {
|
||||
const [sport, setSport] = useState('running')
|
||||
const [selectedDistance, setSelectedDistance] = useState(null)
|
||||
|
||||
@@ -31,7 +36,6 @@ export default function RecordsPage() {
|
||||
enabled: !!selectedDistance,
|
||||
})
|
||||
|
||||
// Sort by standard distance order
|
||||
const sortedRecords = records?.slice().sort((a, b) => {
|
||||
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
|
||||
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
|
||||
@@ -39,10 +43,7 @@ export default function RecordsPage() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Personal Records</h1>
|
||||
|
||||
{/* Sport selector */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{SPORTS.map(s => (
|
||||
<button
|
||||
@@ -67,7 +68,6 @@ export default function RecordsPage() {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Records table */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -84,9 +84,7 @@ export default function RecordsPage() {
|
||||
key={rec.id}
|
||||
onClick={() => setSelectedDistance(rec.distance_label)}
|
||||
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
||||
selectedDistance === rec.distance_label
|
||||
? 'bg-blue-900/20'
|
||||
: 'hover:bg-gray-800/40'
|
||||
selectedDistance === rec.distance_label ? 'bg-blue-900/20' : 'hover:bg-gray-800/40'
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-white">{rec.distance_label}</td>
|
||||
@@ -111,52 +109,29 @@ export default function RecordsPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Progress chart */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
{selectedDistance && history ? (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-1">
|
||||
{selectedDistance} progression
|
||||
</h3>
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-1">{selectedDistance} progression</h3>
|
||||
<p className="text-xs text-gray-600 mb-4">Lower is faster</p>
|
||||
{history.length > 1 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart
|
||||
data={history.map(h => ({
|
||||
date: h.achieved_at,
|
||||
time: h.duration_s,
|
||||
}))}
|
||||
data={history.map(h => ({ date: h.achieved_at, time: h.duration_s }))}
|
||||
margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
|
||||
>
|
||||
<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 yy')}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={40}
|
||||
tickFormatter={formatDuration}
|
||||
reversed
|
||||
/>
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={d => format(new Date(d), 'MMM yy')} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
width={40} tickFormatter={formatDuration} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||
formatter={v => [formatDuration(v), 'Time']}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="time"
|
||||
stroke="#fbbf24"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#fbbf24', r: 4 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line type="monotone" dataKey="time" stroke="#fbbf24" strokeWidth={2}
|
||||
dot={{ fill: '#fbbf24', r: 4 }} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
@@ -175,3 +150,188 @@ export default function RecordsPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteRecords() {
|
||||
const navigate = useNavigate()
|
||||
const { data: records, isLoading } = useQuery({
|
||||
queryKey: ['route-records'],
|
||||
queryFn: () => api.get('/records/routes').then(r => r.data),
|
||||
})
|
||||
|
||||
if (isLoading) return <p className="text-gray-500 text-sm">Loading…</p>
|
||||
|
||||
if (!records?.length) return (
|
||||
<div className="text-center py-16 text-gray-600">
|
||||
<p className="text-4xl mb-3">🗺️</p>
|
||||
<p>No route records yet — create named routes and complete activities on them</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||
<th className="px-3 py-3" />
|
||||
<th className="text-left px-3 py-3 font-medium">Route</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Distance</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Best time</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Pace</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map(rec => (
|
||||
<tr
|
||||
key={rec.route_id}
|
||||
onClick={() => navigate(`/activities/${rec.activity_id}`)}
|
||||
className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<RouteMiniMap polyline={rec.reference_polyline} sportType={rec.sport_type} width={72} height={50} />
|
||||
</td>
|
||||
<td className="px-3 py-3 font-medium text-white">
|
||||
<span className="capitalize text-xs text-gray-500 mr-2">{rec.sport_type}</span>
|
||||
{rec.route_name}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||
{formatDistance(rec.distance_m)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||
{formatDuration(rec.duration_s)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||
{formatPace(rec.avg_speed_ms, rec.sport_type)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||
{formatDate(rec.start_time)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
const [open, setOpen] = useState(null)
|
||||
const { data: segments, isLoading } = useQuery({
|
||||
queryKey: ['segments'],
|
||||
queryFn: () => api.get('/segments/').then(r => r.data),
|
||||
})
|
||||
|
||||
if (isLoading) return <p className="text-gray-500 text-sm">Loading…</p>
|
||||
|
||||
if (!segments?.length) return (
|
||||
<div className="text-center py-16 text-gray-600">
|
||||
<p className="text-4xl mb-3">🏅</p>
|
||||
<p>No segments yet — create one from an activity's detail page</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||
<th className="px-3 py-3" />
|
||||
<th className="text-left px-3 py-3 font-medium">Segment</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Distance</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Best time</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Efforts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{segments.map(seg => (
|
||||
<Fragment key={seg.id}>
|
||||
<tr
|
||||
onClick={() => setOpen(open === seg.id ? null : seg.id)}
|
||||
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
||||
open === seg.id ? 'bg-blue-900/20' : 'hover:bg-gray-800/40'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<RouteMiniMap polyline={seg.polyline} sportType={seg.sport_type} width={72} height={50} />
|
||||
</td>
|
||||
<td 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>}
|
||||
{seg.name}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||
{formatDistance(seg.distance_m)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||
{seg.effort_count}
|
||||
</td>
|
||||
</tr>
|
||||
{open === seg.id && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-0 border-b border-gray-800/50">
|
||||
<SegmentLeaderboard segmentId={seg.id} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RecordsPage() {
|
||||
const [tab, setTab] = useState('Distance PRs')
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Records</h1>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`text-sm px-4 py-1.5 rounded-full border transition-colors ${
|
||||
tab === t
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-gray-700 text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'Distance PRs' && <DistancePRs />}
|
||||
{tab === 'Route Records' && <RouteRecords />}
|
||||
{tab === 'Segments' && <SegmentRecords />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+297
-147
@@ -1,7 +1,235 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { formatDistance, formatDuration, formatDate, formatPace } from '../utils/format'
|
||||
import ActivityMap from '../components/activity/ActivityMap'
|
||||
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
||||
|
||||
// Decode Google encoded polyline to [[lat,lng], ...]
|
||||
function decodePolyline(encoded) {
|
||||
if (!encoded) return []
|
||||
const points = []
|
||||
let idx = 0, lat = 0, lng = 0
|
||||
while (idx < encoded.length) {
|
||||
let shift = 0, result = 0, byte
|
||||
do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20)
|
||||
lat += result & 1 ? ~(result >> 1) : result >> 1
|
||||
shift = 0; result = 0
|
||||
do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20)
|
||||
lng += result & 1 ? ~(result >> 1) : result >> 1
|
||||
points.push([lat / 1e5, lng / 1e5])
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
function RouteMap({ polyline, className = '', sportType = '' }) {
|
||||
const pts = decodePolyline(polyline)
|
||||
if (pts.length < 2) return (
|
||||
<div className={`bg-gray-800 rounded flex items-center justify-center text-gray-600 text-xs ${className}`}>
|
||||
no track
|
||||
</div>
|
||||
)
|
||||
const t = (sportType || '').toLowerCase()
|
||||
const stroke = (t.includes('cycl') || t.includes('bike') || t.includes('ride')) ? '#f97316' : '#3b82f6'
|
||||
const lats = pts.map(p => p[0]), lngs = pts.map(p => p[1])
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||
const rangeL = maxLng - minLng || 1e-5
|
||||
const rangeA = maxLat - minLat || 1e-5
|
||||
const pad = 4
|
||||
const w = 100, h = 60
|
||||
const toX = lng => pad + ((lng - minLng) / rangeL) * (w - pad * 2)
|
||||
const toY = lat => pad + ((maxLat - lat) / rangeA) * (h - pad * 2)
|
||||
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p[1]).toFixed(1)},${toY(p[0]).toFixed(1)}`).join(' ')
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className={`bg-gray-800 rounded ${className}`} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d={d} fill="none" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function routeSportStyle(sportType) {
|
||||
const t = (sportType || '').toLowerCase()
|
||||
if (t.includes('cycl') || t.includes('bike') || t.includes('ride'))
|
||||
return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400' }
|
||||
if (t.includes('run') || t.includes('jog') || t.includes('walk'))
|
||||
return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400' }
|
||||
return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400' }
|
||||
}
|
||||
|
||||
const MEDALS = ['🥇', '🥈', '🥉']
|
||||
|
||||
function RouteDetail({ selected, setSelected }) {
|
||||
const qc = useQueryClient()
|
||||
const [merging, setMerging] = useState(false)
|
||||
const [mergeTarget, setMergeTarget] = useState('')
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [nameInput, setNameInput] = useState(selected.name)
|
||||
|
||||
const { data: routes } = useQuery({
|
||||
queryKey: ['routes'],
|
||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: routeActivities } = useQuery({
|
||||
queryKey: ['route-activities', selected.id],
|
||||
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
|
||||
})
|
||||
|
||||
const renameRoute = useMutation({
|
||||
mutationFn: name => api.patch(`/routes/${selected.id}`, { name }).then(r => r.data),
|
||||
onSuccess: updated => {
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
setSelected(updated)
|
||||
setEditingName(false)
|
||||
},
|
||||
})
|
||||
|
||||
const mergeRoute = useMutation({
|
||||
mutationFn: ({ into, from }) => api.post(`/routes/${into}/merge/${from}`).then(r => r.data),
|
||||
onSuccess: updated => {
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
qc.invalidateQueries({ queryKey: ['route-activities', updated.id] })
|
||||
setMerging(false)
|
||||
setMergeTarget('')
|
||||
setSelected(updated)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteRoute = useMutation({
|
||||
mutationFn: id => api.delete(`/routes/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
setSelected(null)
|
||||
},
|
||||
})
|
||||
|
||||
const fastest = routeActivities?.[0]
|
||||
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)
|
||||
@@ -14,192 +242,114 @@ export default function RoutesPage() {
|
||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: routeActivities } = useQuery({
|
||||
queryKey: ['route-activities', selected?.id],
|
||||
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
|
||||
enabled: !!selected,
|
||||
})
|
||||
// Sort by most completions first
|
||||
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
|
||||
|
||||
const { data: segments } = useQuery({
|
||||
queryKey: ['route-segments', selected?.id],
|
||||
queryFn: () => api.get(`/routes/${selected.id}/segments`).then(r => r.data),
|
||||
enabled: !!selected,
|
||||
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: () => {
|
||||
mutationFn: data => api.post('/routes/', data).then(r => r.data),
|
||||
onSuccess: route => {
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
setShowCreate(false)
|
||||
setNewRoute({ name: '', activity_id: '' })
|
||||
setSelected(route)
|
||||
},
|
||||
})
|
||||
|
||||
const fastest = routeActivities?.[0]
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Routes are auto-detected when you run the same path twice. You can also create them manually.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
+ New route
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create route modal */}
|
||||
{/* Create route panel */}
|
||||
{showCreate && (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white">Create named route</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Pick an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
|
||||
Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
||||
<input
|
||||
value={newRoute.name}
|
||||
onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
||||
<input value={newRoute.name} onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. Morning park loop"
|
||||
/>
|
||||
placeholder="e.g. Morning park loop" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Reference activity ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newRoute.activity_id}
|
||||
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Activity ID"
|
||||
/>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
|
||||
<select value={newRoute.activity_id} onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select an activity…</option>
|
||||
{recentActivities?.map(a => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{sportIcon(a.sport_type)} {a.name} — {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
||||
disabled={!newRoute.name || !newRoute.activity_id}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<button onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
||||
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<button onClick={() => setShowCreate(false)}
|
||||
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Route list */}
|
||||
<div className="space-y-2">
|
||||
{routes?.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-600">
|
||||
<p className="text-3xl mb-2">🗺️</p>
|
||||
<p className="text-sm">No named routes yet</p>
|
||||
</div>
|
||||
)}
|
||||
{routes?.map(route => (
|
||||
<button
|
||||
key={route.id}
|
||||
onClick={() => setSelected(route)}
|
||||
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
||||
selected?.id === route.id
|
||||
? 'bg-blue-900/20 border-blue-700'
|
||||
: 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-white">{route.name}</p>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
||||
<span>{formatDistance(route.distance_m)}</span>
|
||||
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
||||
<span>{formatDate(route.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{/* Route tile grid — selected route's detail expands inline under its row */}
|
||||
{routes?.length === 0 && !showCreate ? (
|
||||
<div className="text-center py-12 text-gray-600">
|
||||
<p className="text-3xl mb-2">🗺️</p>
|
||||
<p className="text-sm">No named routes yet</p>
|
||||
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||
</div>
|
||||
|
||||
{/* Route detail */}
|
||||
{selected && (
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<h2 className="text-lg font-semibold text-white mb-1">{selected.name}</h2>
|
||||
{selected.description && (
|
||||
<p className="text-sm text-gray-400 mb-3">{selected.description}</p>
|
||||
)}
|
||||
|
||||
{/* CR */}
|
||||
{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 className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
{sortedRoutes.map(route => {
|
||||
const style = routeSportStyle(route.sport_type)
|
||||
const isSelected = selected?.id === route.id
|
||||
return [
|
||||
<button key={route.id}
|
||||
onClick={() => setSelected(isSelected ? null : route)}
|
||||
className={`text-left rounded-xl border p-2 transition-all ${
|
||||
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
|
||||
}`}>
|
||||
<RouteMap polyline={route.reference_polyline} className="w-full h-20" sportType={route.sport_type} />
|
||||
<p className="text-xs font-medium text-white mt-2 truncate">{route.name}</p>
|
||||
<div className="flex items-center justify-between mt-0.5 gap-1">
|
||||
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
|
||||
{route.activity_count > 0 && (
|
||||
<span className={`text-xs font-medium ${style.accent}`}>{route.activity_count}×</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All runs on route */}
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
||||
All runs ({routeActivities?.length ?? 0})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{routeActivities?.map((act, i) => (
|
||||
<div
|
||||
key={act.id}
|
||||
className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm"
|
||||
>
|
||||
<span className="text-gray-600 w-5 text-right">{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>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Segments */}
|
||||
{segments && segments.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
|
||||
<div className="space-y-2">
|
||||
{segments.map(seg => (
|
||||
<div key={seg.id} className="flex items-center justify-between py-2 border-b border-gray-800/50">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{seg.name}</p>
|
||||
{seg.description && (
|
||||
<p className="text-xs text-gray-500">{seg.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 text-right">
|
||||
<p>{formatDistance(seg.start_distance_m)} → {formatDistance(seg.end_distance_m)}</p>
|
||||
<p>{formatDistance(seg.end_distance_m - seg.start_distance_m)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{route.auto_detected && <span className="text-xs text-gray-600">auto</span>}
|
||||
</button>,
|
||||
isSelected && <RouteDetail key={`detail-${route.id}`} selected={selected} setSelected={setSelected} />,
|
||||
]
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
|
||||
function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
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]
|
||||
// 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 =>
|
||||
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' && !skipped) {
|
||||
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({
|
||||
mutationFn: async (file) => {
|
||||
@@ -16,7 +51,15 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
return { file: file.name, ...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)
|
||||
}
|
||||
},
|
||||
onError: (err, file) => {
|
||||
const reason = err.response?.data?.detail || 'Upload failed'
|
||||
setTasks(t => [...t, { file: file?.name || String(file), status: 'failed', reason }])
|
||||
},
|
||||
})
|
||||
|
||||
@@ -30,6 +73,14 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
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 === 'skipped') return <span className="ml-2 text-amber-400">⚠ Skipped</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 (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
@@ -68,12 +119,19 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
{tasks.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{tasks.map((task, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-300 truncate flex-1">{task.file}</span>
|
||||
{task.activity_tasks !== undefined && (
|
||||
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
|
||||
<div key={i} className="bg-gray-800 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-300 truncate flex-1">{task.file}</span>
|
||||
{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>
|
||||
)}
|
||||
<span className="ml-2 text-green-400">✓ Queued</span>
|
||||
</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'
|
||||
}
|
||||
@@ -10,8 +10,7 @@ export function formatDuration(seconds) {
|
||||
export function formatPace(speedMs, sportType = 'running') {
|
||||
if (!speedMs || speedMs <= 0) return '--'
|
||||
if (sportType === 'cycling') {
|
||||
const kph = speedMs * 3.6
|
||||
return `${kph.toFixed(1)} km/h`
|
||||
return `${(speedMs * 3.6).toFixed(1)} km/h`
|
||||
}
|
||||
const secsPerKm = 1000 / speedMs
|
||||
const mins = Math.floor(secsPerKm / 60)
|
||||
@@ -62,6 +61,17 @@ export function formatDateTime(dateStr) {
|
||||
})
|
||||
}
|
||||
|
||||
export function formatCadence(value, sportType) {
|
||||
if (!value) return '--'
|
||||
// Garmin stores running cadence as steps per minute / 2 (one foot)
|
||||
// We need to double it to get total steps per minute (both feet)
|
||||
if (sportType === 'running' || sportType === 'hiking' || sportType === 'walking') {
|
||||
return `${Math.round(value * 2)} spm`
|
||||
}
|
||||
// Cycling is already in rpm
|
||||
return `${Math.round(value)} rpm`
|
||||
}
|
||||
|
||||
export function hrZoneColor(zone) {
|
||||
const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' }
|
||||
return colors[zone] || '#9ca3af'
|
||||
@@ -69,7 +79,7 @@ export function hrZoneColor(zone) {
|
||||
|
||||
export function sportIcon(sportType) {
|
||||
const icons = {
|
||||
running: '🏃', cycling: '🚴', swimming: '🏊', hiking: '🥾',
|
||||
running: '🏃', cycling: '🚴', hiking: '🥾',
|
||||
walking: '🚶', other: '⚡',
|
||||
}
|
||||
return icons[sportType?.toLowerCase()] || '⚡'
|
||||
@@ -77,7 +87,7 @@ export function sportIcon(sportType) {
|
||||
|
||||
export function sportColor(sportType) {
|
||||
const colors = {
|
||||
running: '#3b82f6', cycling: '#f97316', swimming: '#06b6d4',
|
||||
running: '#3b82f6', cycling: '#f97316',
|
||||
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
|
||||
}
|
||||
return colors[sportType?.toLowerCase()] || '#6b7280'
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Project a lat/lng onto the activity's GPS track, returning the nearest point
|
||||
// *along the line* (not just the nearest recorded sample) with an interpolated
|
||||
// cumulative distance. This gives smooth snapping anywhere along the route rather
|
||||
// than jumping between logged GPS points.
|
||||
//
|
||||
// Coordinates are treated as planar (lat/lng as y/x). At the scale of a single
|
||||
// activity this is accurate enough for visual snapping and segment selection.
|
||||
export function projectToTrack(points, lat, lng) {
|
||||
const valid = points.filter(p => p.latitude != null && p.longitude != null)
|
||||
if (valid.length === 0) return null
|
||||
if (valid.length === 1) {
|
||||
return { latitude: valid[0].latitude, longitude: valid[0].longitude, distance_m: valid[0].distance_m ?? 0 }
|
||||
}
|
||||
|
||||
let best = null
|
||||
let bestD = Infinity
|
||||
for (let i = 0; i < valid.length - 1; i++) {
|
||||
const a = valid[i]
|
||||
const b = valid[i + 1]
|
||||
const ax = a.longitude, ay = a.latitude
|
||||
const dx = b.longitude - ax, dy = b.latitude - ay
|
||||
const len2 = dx * dx + dy * dy
|
||||
let t = len2 > 0 ? (((lng - ax) * dx + (lat - ay) * dy) / len2) : 0
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
const px = ax + t * dx
|
||||
const py = ay + t * dy
|
||||
const d = (lat - py) ** 2 + (lng - px) ** 2
|
||||
if (d < bestD) {
|
||||
bestD = d
|
||||
const da = a.distance_m, db = b.distance_m
|
||||
const dist = (da != null && db != null) ? da + (db - da) * t : (da ?? db ?? null)
|
||||
best = { latitude: py, longitude: px, distance_m: dist }
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# FitTracker configuration
|
||||
# Copy this file to .env and edit, OR just run: bash install.sh
|
||||
# install.sh auto-generates all secrets for you.
|
||||
|
||||
# ── Required ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Login for the web interface
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
# Security: generate with: openssl rand -hex 32
|
||||
SECRET_KEY=changeme_run_openssl_rand_hex_32
|
||||
|
||||
# Database password
|
||||
DB_PASSWORD=changeme
|
||||
DB_USER=fittracker
|
||||
|
||||
# Redis password
|
||||
REDIS_PASSWORD=changeme
|
||||
|
||||
# ── Optional ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Port to expose (default: 80)
|
||||
HTTP_PORT=80
|
||||
|
||||
# Mapbox token for satellite map tiles — free at mapbox.com
|
||||
# Leave blank to use OpenStreetMap (CartoDB dark tiles, no key needed)
|
||||
VITE_MAPBOX_TOKEN=
|
||||
|
||||
# PocketID passkey authentication — leave blank to use local auth only
|
||||
# See: https://github.com/pocket-id/pocket-id
|
||||
POCKETID_ISSUER=
|
||||
POCKETID_CLIENT_ID=
|
||||
POCKETID_CLIENT_SECRET=
|
||||
# Restrict sign-in to members of this PocketID group (leave blank to allow all)
|
||||
POCKETID_ALLOWED_GROUP=
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Build and push images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: # allow manual trigger from Gitea UI
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ vars.GITEA_URL }} # e.g. gitea.yourdomain.com — set in repo Settings → Variables
|
||||
OWNER: ${{ gitea.repository_owner }}
|
||||
|
||||
jobs:
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
|
||||
- name: Build and push backend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:latest
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:${{ gitea.sha }}
|
||||
|
||||
build-worker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
|
||||
- name: Build and push worker
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile.worker
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:latest
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:${{ gitea.sha }}
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
|
||||
- name: Build and push frontend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:latest
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:${{ gitea.sha }}
|
||||
build-args: |
|
||||
VITE_API_URL=/api
|
||||
VITE_MAPBOX_TOKEN=
|
||||
@@ -0,0 +1,12 @@
|
||||
.env
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
*.sql.bak
|
||||
db_data/
|
||||
redis_data/
|
||||
file_data/
|
||||
@@ -0,0 +1,153 @@
|
||||
# MileVault
|
||||
|
||||
Self-hosted fitness tracking — Garmin & Strava import, maps, health trends, personal records.
|
||||
|
||||
---
|
||||
|
||||
## For users — deploy with two files
|
||||
|
||||
Once this repo is pushed to Gitea and the Actions workflow has run once, anyone on your network only needs **two files** to run MileVault. No source code, no cloning.
|
||||
|
||||
```bash
|
||||
mkdir milevault && cd milevault
|
||||
|
||||
# Download the two deployment files
|
||||
curl -O https://gitea.yourdomain.com/yourusername/milevault/raw/branch/main/docker-compose.deploy.yml
|
||||
curl -O https://gitea.yourdomain.com/yourusername/milevault/raw/branch/main/nginx.conf
|
||||
|
||||
# Start (images pulled automatically from your Gitea registry)
|
||||
docker compose -f docker-compose.deploy.yml up -d
|
||||
```
|
||||
|
||||
Default login: `admin` / `admin`
|
||||
**Change `ADMIN_PASSWORD` in a `.env` file before exposing to a network** (see Configuration below).
|
||||
|
||||
To update when a new version is pushed to Gitea:
|
||||
```bash
|
||||
docker compose -f docker-compose.deploy.yml pull
|
||||
docker compose -f docker-compose.deploy.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For developers — first-time Gitea setup
|
||||
|
||||
### 1. Enable the Gitea container registry
|
||||
|
||||
In your Gitea instance (`app.ini` or admin panel):
|
||||
|
||||
```ini
|
||||
[packages]
|
||||
ENABLED = true
|
||||
```
|
||||
|
||||
Restart Gitea. The registry is then available at `gitea.yourdomain.com`.
|
||||
|
||||
### 2. Create a Gitea Actions runner
|
||||
|
||||
Gitea Actions needs a runner on your server:
|
||||
|
||||
```bash
|
||||
# On the server that will build images
|
||||
docker run -d \
|
||||
--name gitea-runner \
|
||||
--restart always \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v gitea-runner-data:/data \
|
||||
-e GITEA_INSTANCE_URL=https://gitea.yourdomain.com \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token from Gitea → Settings → Runners> \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
Get the registration token from: **Gitea → Your repo → Settings → Actions → Runners → Create Runner**
|
||||
|
||||
### 3. Create a package token
|
||||
|
||||
The workflow needs a token to push images to the registry:
|
||||
|
||||
1. Gitea → Your profile → **Settings → Applications → Generate Token**
|
||||
2. Scopes: tick **`write:package`**
|
||||
3. Copy the token
|
||||
|
||||
Then in your repo: **Settings → Secrets → Actions → Add Secret**
|
||||
- Name: `PACKAGE_TOKEN`
|
||||
- Value: the token you just copied
|
||||
|
||||
### 4. Set the registry URL variable
|
||||
|
||||
In your repo: **Settings → Variables → Actions → Add Variable**
|
||||
- Name: `GITEA_URL`
|
||||
- Value: `gitea.yourdomain.com` (no `https://`)
|
||||
|
||||
### 5. Push the repo
|
||||
|
||||
```bash
|
||||
git remote add origin https://gitea.yourdomain.com/yourusername/milevault.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
The Actions workflow (`.gitea/workflows/build.yml`) triggers automatically, builds all three images, and pushes them to your Gitea registry. Check progress under **Actions** in the Gitea UI.
|
||||
|
||||
### 6. Update docker-compose.deploy.yml
|
||||
|
||||
Before the first deploy, replace the placeholder registry URLs in `docker-compose.deploy.yml`:
|
||||
|
||||
```
|
||||
gitea.yourdomain.com/yourusername/ → your actual Gitea host and username
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a `.env` file next to `docker-compose.deploy.yml` to override any defaults:
|
||||
|
||||
```env
|
||||
# Admin login
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=a_strong_password_here
|
||||
|
||||
# Generate with: openssl rand -hex 32
|
||||
SECRET_KEY=
|
||||
|
||||
# Ports
|
||||
HTTP_PORT=80
|
||||
|
||||
# Optional: Mapbox token for satellite tiles
|
||||
VITE_MAPBOX_TOKEN=
|
||||
|
||||
# Optional: PocketID passkey auth
|
||||
POCKETID_ISSUER=
|
||||
POCKETID_CLIENT_ID=
|
||||
POCKETID_CLIENT_SECRET=
|
||||
```
|
||||
|
||||
Docker Compose picks up `.env` automatically.
|
||||
|
||||
---
|
||||
|
||||
## If your Gitea registry requires authentication to pull
|
||||
|
||||
If your Gitea instance is private, add a pull secret on the deploy machine:
|
||||
|
||||
```bash
|
||||
docker login gitea.yourdomain.com
|
||||
# enter your Gitea username and password (or a read:package token)
|
||||
```
|
||||
|
||||
Docker stores the credentials in `~/.docker/config.json` and uses them automatically on `docker compose pull`.
|
||||
|
||||
---
|
||||
|
||||
## Repo structure
|
||||
|
||||
```
|
||||
.gitea/workflows/build.yml ← Gitea Actions: builds & pushes images on push to main
|
||||
docker-compose.yml ← dev/build compose (builds from source)
|
||||
docker-compose.deploy.yml ← production compose (pulls pre-built images)
|
||||
nginx.conf ← standalone nginx config for deploy compose
|
||||
backend/ ← FastAPI + Celery worker
|
||||
frontend/ ← React + Vite
|
||||
nginx/nginx.conf ← nginx config for dev compose
|
||||
docker/init.sql ← DB init (enables TimescaleDB extension)
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl build-essential libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Single worker avoids race condition during DB initialization.
|
||||
# For a personal app this is fine; async handles concurrent requests well.
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
|
||||
@@ -0,0 +1,271 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc, delete
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
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, Activity, ActivityDataPoint, ActivityLap
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ActivitySummary(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
sport_type: str
|
||||
start_time: datetime
|
||||
distance_m: Optional[float]
|
||||
duration_s: Optional[float]
|
||||
elevation_gain_m: Optional[float]
|
||||
avg_heart_rate: Optional[float]
|
||||
avg_cadence: Optional[float]
|
||||
avg_speed_ms: Optional[float]
|
||||
calories: Optional[float]
|
||||
polyline: Optional[str]
|
||||
bounding_box: Optional[dict]
|
||||
hr_zones: Optional[dict]
|
||||
named_route_id: Optional[int]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ActivityDetail(ActivitySummary):
|
||||
end_time: Optional[datetime]
|
||||
elevation_loss_m: Optional[float]
|
||||
max_heart_rate: Optional[float]
|
||||
avg_power: Optional[float]
|
||||
normalized_power: Optional[float]
|
||||
max_speed_ms: Optional[float]
|
||||
avg_temperature_c: Optional[float]
|
||||
training_stress_score: Optional[float]
|
||||
vo2max_estimate: Optional[float]
|
||||
|
||||
|
||||
class DataPointOut(BaseModel):
|
||||
timestamp: Optional[datetime]
|
||||
latitude: Optional[float]
|
||||
longitude: Optional[float]
|
||||
altitude_m: Optional[float]
|
||||
heart_rate: Optional[float]
|
||||
cadence: Optional[float]
|
||||
speed_ms: Optional[float]
|
||||
power: Optional[float]
|
||||
temperature_c: Optional[float]
|
||||
distance_m: Optional[float]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LapOut(BaseModel):
|
||||
lap_number: int
|
||||
start_time: Optional[datetime]
|
||||
duration_s: Optional[float]
|
||||
distance_m: Optional[float]
|
||||
avg_heart_rate: Optional[float]
|
||||
avg_cadence: Optional[float]
|
||||
avg_speed_ms: Optional[float]
|
||||
avg_power: Optional[float]
|
||||
|
||||
class Config:
|
||||
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])
|
||||
async def list_activities(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
sport_type: Optional[str] = None,
|
||||
from_date: Optional[datetime] = None,
|
||||
to_date: Optional[datetime] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
q = select(Activity).where(Activity.user_id == current_user.id)
|
||||
|
||||
if sport_type:
|
||||
q = q.where(Activity.sport_type == sport_type)
|
||||
if from_date:
|
||||
q = q.where(Activity.start_time >= from_date)
|
||||
if to_date:
|
||||
q = q.where(Activity.start_time <= to_date)
|
||||
|
||||
q = q.order_by(desc(Activity.start_time))
|
||||
q = q.offset((page - 1) * per_page).limit(per_page)
|
||||
|
||||
result = await db.execute(q)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{activity_id}", response_model=ActivityDetail)
|
||||
async def get_activity(
|
||||
activity_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
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")
|
||||
return activity
|
||||
|
||||
|
||||
@router.get("/{activity_id}/data-points", response_model=List[DataPointOut])
|
||||
async def get_data_points(
|
||||
activity_id: int,
|
||||
downsample: int = Query(0, ge=0, description="Return every Nth point; 0 = all"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
act = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.id == activity_id,
|
||||
Activity.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if not act.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
|
||||
q = select(ActivityDataPoint).where(
|
||||
ActivityDataPoint.activity_id == activity_id
|
||||
).order_by(ActivityDataPoint.timestamp)
|
||||
|
||||
result = await db.execute(q)
|
||||
points = result.scalars().all()
|
||||
|
||||
if downsample > 1:
|
||||
points = points[::downsample]
|
||||
|
||||
return points
|
||||
|
||||
|
||||
@router.get("/{activity_id}/laps", response_model=List[LapOut])
|
||||
async def get_laps(
|
||||
activity_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
act = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.id == activity_id,
|
||||
Activity.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if not act.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(ActivityLap)
|
||||
.where(ActivityLap.activity_id == activity_id)
|
||||
.order_by(ActivityLap.lap_number)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.patch("/{activity_id}/name")
|
||||
async def rename_activity(
|
||||
activity_id: int,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
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")
|
||||
|
||||
activity.name = body.get("name", activity.name)
|
||||
await db.commit()
|
||||
return {"id": activity_id, "name": activity.name}
|
||||
|
||||
|
||||
@router.delete("/{activity_id}", status_code=204)
|
||||
async def delete_activity(
|
||||
activity_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
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")
|
||||
await db.delete(activity)
|
||||
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"}
|
||||
@@ -0,0 +1,275 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import timedelta
|
||||
from jose import jwt, JWTError
|
||||
import httpx
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, create_access_token, get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
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):
|
||||
"""Get PocketID config from DB (admin user) falling back to env vars."""
|
||||
admin = await _config_admin(db)
|
||||
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
|
||||
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
|
||||
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):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user_id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str]
|
||||
is_admin: bool
|
||||
has_passkey: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.username == form_data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.hashed_password:
|
||||
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||
if not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return Token(access_token=token, token_type="bearer",
|
||||
user_id=user.id, username=user.username, is_admin=user.is_admin)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_me(current_user: User = Depends(get_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")
|
||||
async def pocketid_available(db: AsyncSession = Depends(get_db)):
|
||||
issuer, client_id, _ = await _get_pocketid_config(db)
|
||||
return {"available": bool(issuer and client_id)}
|
||||
|
||||
|
||||
@router.get("/pocketid/login-url")
|
||||
async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
||||
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",
|
||||
}
|
||||
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
||||
|
||||
|
||||
@router.get("/pocketid/link-url")
|
||||
async def pocketid_link_url(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Authenticated user starts an OIDC flow to attach a passkey to THEIR account.
|
||||
|
||||
The `state` carries a signed 'link to this user' token so the callback links
|
||||
the returned identity instead of creating/matching a new account.
|
||||
"""
|
||||
issuer, client_id, _ = await _get_pocketid_config(db)
|
||||
if not issuer or not client_id:
|
||||
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||
from urllib.parse import urlencode
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email groups",
|
||||
"state": _make_link_state(current_user.id),
|
||||
}
|
||||
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
||||
|
||||
|
||||
@router.get("/pocketid/callback")
|
||||
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)
|
||||
if not issuer:
|
||||
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{issuer}/api/oidc/token",
|
||||
data={"grant_type": "authorization_code", "code": code,
|
||||
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||
"client_id": client_id, "client_secret": client_secret},
|
||||
)
|
||||
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")
|
||||
tokens = resp.json()
|
||||
userinfo_resp = await client.get(
|
||||
f"{issuer}/api/oidc/userinfo",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
userinfo = userinfo_resp.json()
|
||||
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
sub = userinfo.get("sub")
|
||||
email = userinfo.get("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))
|
||||
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:
|
||||
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)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
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"}
|
||||
@@ -0,0 +1,187 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, func
|
||||
from pydantic import BaseModel, model_validator
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User, HealthMetric
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class HealthMetricOut(BaseModel):
|
||||
id: int
|
||||
date: datetime
|
||||
resting_hr: Optional[float]
|
||||
max_hr_day: Optional[float]
|
||||
avg_hr_day: Optional[float]
|
||||
hrv_nightly_avg: Optional[float]
|
||||
hrv_status: Optional[str]
|
||||
hrv_5min_high: Optional[float]
|
||||
hrv_5min_low: Optional[float]
|
||||
sleep_duration_s: Optional[float]
|
||||
sleep_deep_s: Optional[float]
|
||||
sleep_light_s: Optional[float]
|
||||
sleep_rem_s: Optional[float]
|
||||
sleep_awake_s: Optional[float]
|
||||
sleep_score: Optional[float]
|
||||
sleep_start: Optional[datetime]
|
||||
sleep_end: Optional[datetime]
|
||||
weight_kg: Optional[float]
|
||||
bmi: Optional[float]
|
||||
body_fat_pct: Optional[float]
|
||||
muscle_mass_kg: Optional[float]
|
||||
vo2max: Optional[float]
|
||||
fitness_age: Optional[int]
|
||||
training_load: Optional[float]
|
||||
recovery_time_h: Optional[float]
|
||||
avg_stress: Optional[float]
|
||||
steps: Optional[int]
|
||||
floors_climbed: Optional[int]
|
||||
active_calories: Optional[float]
|
||||
total_calories: 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:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[HealthMetricOut])
|
||||
async def list_health_metrics(
|
||||
from_date: Optional[datetime] = None,
|
||||
to_date: Optional[datetime] = None,
|
||||
limit: int = Query(365, ge=1, le=2000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
|
||||
|
||||
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)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def health_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
latest_result = await db.execute(
|
||||
select(HealthMetric)
|
||||
.where(HealthMetric.user_id == current_user.id)
|
||||
.order_by(desc(HealthMetric.date))
|
||||
.limit(1)
|
||||
)
|
||||
latest = latest_result.scalar_one_or_none()
|
||||
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
|
||||
avg_result = await db.execute(
|
||||
select(
|
||||
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
|
||||
func.avg(HealthMetric.hrv_nightly_avg).label("avg_hrv"),
|
||||
func.avg(HealthMetric.sleep_duration_s).label("avg_sleep_s"),
|
||||
func.avg(HealthMetric.sleep_score).label("avg_sleep_score"),
|
||||
func.avg(HealthMetric.avg_stress).label("avg_stress"),
|
||||
func.avg(HealthMetric.steps).label("avg_steps"),
|
||||
func.avg(HealthMetric.weight_kg).label("avg_weight"),
|
||||
).where(
|
||||
HealthMetric.user_id == current_user.id,
|
||||
func.date(HealthMetric.date) >= cutoff,
|
||||
)
|
||||
)
|
||||
avgs = avg_result.one()
|
||||
|
||||
return {
|
||||
"latest": HealthMetricOut.model_validate(latest) if latest else None,
|
||||
"avg_30d": {
|
||||
"resting_hr": avgs.avg_resting_hr,
|
||||
"hrv": avgs.avg_hrv,
|
||||
"sleep_h": (avgs.avg_sleep_s / 3600) if avgs.avg_sleep_s else None,
|
||||
"sleep_score": avgs.avg_sleep_score,
|
||||
"stress": avgs.avg_stress,
|
||||
"steps": avgs.avg_steps,
|
||||
"weight_kg": avgs.avg_weight,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@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")
|
||||
async def add_manual_metric(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from fastapi import HTTPException
|
||||
date_str = body.get("date")
|
||||
if not date_str:
|
||||
raise HTTPException(status_code=400, detail="date required")
|
||||
|
||||
metric_date = datetime.fromisoformat(date_str)
|
||||
|
||||
existing = await db.execute(
|
||||
select(HealthMetric).where(
|
||||
HealthMetric.user_id == current_user.id,
|
||||
func.date(HealthMetric.date) == metric_date.date(),
|
||||
)
|
||||
)
|
||||
metric = existing.scalar_one_or_none()
|
||||
|
||||
if metric:
|
||||
for key, val in body.items():
|
||||
if hasattr(metric, key) and key not in ("id", "user_id"):
|
||||
setattr(metric, key, val)
|
||||
else:
|
||||
metric = HealthMetric(user_id=current_user.id, date=metric_date, **{
|
||||
k: v for k, v in body.items()
|
||||
if hasattr(HealthMetric, k) and k not in ("id", "user_id")
|
||||
})
|
||||
db.add(metric)
|
||||
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,239 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user, hash_password, verify_password
|
||||
from app.models.user import User, WeightLog
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Profile ────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
max_heart_rate: Optional[int] = None
|
||||
resting_heart_rate: Optional[int] = None
|
||||
birth_year: Optional[int] = None
|
||||
height_cm: Optional[float] = None
|
||||
biological_sex: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str]
|
||||
max_heart_rate: Optional[int]
|
||||
resting_heart_rate: Optional[int]
|
||||
birth_year: Optional[int]
|
||||
height_cm: Optional[float]
|
||||
biological_sex: Optional[str]
|
||||
estimated_max_hr: Optional[int]
|
||||
is_admin: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
def _estimated_max_hr(user: User) -> Optional[int]:
|
||||
if user.birth_year:
|
||||
return 220 - (datetime.now().year - user.birth_year)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=ProfileOut)
|
||||
async def get_profile(current_user: User = Depends(get_current_user)):
|
||||
return {**{c.name: getattr(current_user, c.name)
|
||||
for c in User.__table__.columns},
|
||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||||
|
||||
|
||||
@router.patch("/", response_model=ProfileOut)
|
||||
async def update_profile(
|
||||
body: ProfileUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
old_max_hr = current_user.max_heart_rate
|
||||
if body.max_heart_rate is not None:
|
||||
if not (100 <= body.max_heart_rate <= 250):
|
||||
raise HTTPException(400, "Max HR must be 100–250")
|
||||
current_user.max_heart_rate = body.max_heart_rate
|
||||
if body.resting_heart_rate is not None:
|
||||
if not (20 <= body.resting_heart_rate <= 120):
|
||||
raise HTTPException(400, "Resting HR must be 20–120")
|
||||
current_user.resting_heart_rate = body.resting_heart_rate
|
||||
if body.birth_year is not None:
|
||||
if not (1920 <= body.birth_year <= 2010):
|
||||
raise HTTPException(400, "Invalid birth year")
|
||||
current_user.birth_year = body.birth_year
|
||||
if body.height_cm is not None:
|
||||
if not (50 <= body.height_cm <= 300):
|
||||
raise HTTPException(400, "Height must be 50–300 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.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)
|
||||
for c in User.__table__.columns},
|
||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||||
|
||||
|
||||
# ── Password change ────────────────────────────────────────────────────────
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: PasswordChange,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not current_user.hashed_password:
|
||||
raise HTTPException(400, "Account uses passkey login — no password to change")
|
||||
if not verify_password(body.current_password, current_user.hashed_password):
|
||||
raise HTTPException(400, "Current password is incorrect")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(400, "New password must be at least 8 characters")
|
||||
current_user.hashed_password = hash_password(body.new_password)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── PocketID configuration (admin only) ────────────────────────────────────
|
||||
|
||||
class PocketIDConfig(BaseModel):
|
||||
issuer: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
client_secret: Optional[str] = None
|
||||
allowed_group: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/pocketid-config")
|
||||
async def get_pocketid_config(current_user: User = Depends(get_current_user)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
from app.core.config import settings
|
||||
# Show DB config if set, fall back to env
|
||||
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
|
||||
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 {
|
||||
"issuer": issuer or "",
|
||||
"client_id": client_id or "",
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pocketid-config")
|
||||
async def save_pocketid_config(
|
||||
body: PocketIDConfig,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
if body.issuer is not None:
|
||||
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
|
||||
if body.client_id is not None:
|
||||
current_user.pocketid_client_id = body.client_id or None
|
||||
# Only overwrite the secret when a non-empty value is supplied; a blank
|
||||
# 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()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Weight log ─────────────────────────────────────────────────────────────
|
||||
|
||||
class WeightEntry(BaseModel):
|
||||
date: datetime
|
||||
weight_kg: float
|
||||
body_fat_pct: Optional[float] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class WeightOut(BaseModel):
|
||||
id: int
|
||||
date: datetime
|
||||
weight_kg: float
|
||||
body_fat_pct: Optional[float]
|
||||
note: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/weight", response_model=List[WeightOut])
|
||||
async def list_weight(
|
||||
limit: int = 365,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(WeightLog)
|
||||
.where(WeightLog.user_id == current_user.id)
|
||||
.order_by(desc(WeightLog.date))
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/weight", response_model=WeightOut)
|
||||
async def log_weight(
|
||||
body: WeightEntry,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not (20 <= body.weight_kg <= 500):
|
||||
raise HTTPException(400, "Weight must be 20–500 kg")
|
||||
entry = WeightLog(
|
||||
user_id=current_user.id,
|
||||
date=body.date,
|
||||
weight_kg=body.weight_kg,
|
||||
body_fat_pct=body.body_fat_pct,
|
||||
note=body.note,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete("/weight/{entry_id}", status_code=204)
|
||||
async def delete_weight(
|
||||
entry_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(WeightLog).where(
|
||||
WeightLog.id == entry_id,
|
||||
WeightLog.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(404, "Not found")
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,92 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
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, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── Personal Records ────────────────────────────────────────────────────────
|
||||
|
||||
class PROut(BaseModel):
|
||||
id: int
|
||||
sport_type: str
|
||||
distance_m: float
|
||||
distance_label: str
|
||||
duration_s: float
|
||||
achieved_at: datetime
|
||||
activity_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[PROut])
|
||||
async def list_records(
|
||||
sport_type: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
q = select(PersonalRecord).where(
|
||||
PersonalRecord.user_id == current_user.id,
|
||||
PersonalRecord.is_current_record == True,
|
||||
)
|
||||
if sport_type:
|
||||
q = q.where(PersonalRecord.sport_type == sport_type)
|
||||
q = q.order_by(PersonalRecord.distance_m)
|
||||
result = await db.execute(q)
|
||||
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}")
|
||||
async def record_history(
|
||||
distance_label: str,
|
||||
sport_type: str = "running",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Show progression of a PR over time."""
|
||||
result = await db.execute(
|
||||
select(PersonalRecord).where(
|
||||
PersonalRecord.user_id == current_user.id,
|
||||
PersonalRecord.sport_type == sport_type,
|
||||
PersonalRecord.distance_label == distance_label,
|
||||
).order_by(PersonalRecord.achieved_at)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,572 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User, NamedRoute, RouteSegment, Activity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SegmentCreate(BaseModel):
|
||||
name: str
|
||||
start_distance_m: float
|
||||
end_distance_m: float
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class RouteCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
sport_type: Optional[str] = None
|
||||
activity_id: int
|
||||
|
||||
|
||||
class RouteOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
sport_type: Optional[str]
|
||||
reference_polyline: Optional[str]
|
||||
bounding_box: Optional[dict]
|
||||
distance_m: Optional[float]
|
||||
auto_detected: Optional[bool]
|
||||
created_at: datetime
|
||||
activity_count: int = 0
|
||||
|
||||
class Config:
|
||||
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])
|
||||
async def list_routes(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Fetch routes with activity counts in one query
|
||||
count_subq = (
|
||||
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
|
||||
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
|
||||
.group_by(Activity.named_route_id)
|
||||
.subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
|
||||
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
|
||||
.where(NamedRoute.user_id == current_user.id)
|
||||
.order_by(desc(NamedRoute.created_at))
|
||||
)
|
||||
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")
|
||||
async def recent_activities_for_route(
|
||||
days: int = Query(14, ge=1, le=90),
|
||||
sport_type: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return recent activities for the route creation dropdown."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
q = select(Activity).where(
|
||||
Activity.user_id == current_user.id,
|
||||
Activity.start_time >= cutoff,
|
||||
Activity.sport_type != "swimming",
|
||||
)
|
||||
if sport_type:
|
||||
q = q.where(Activity.sport_type == sport_type)
|
||||
q = q.order_by(desc(Activity.start_time)).limit(50)
|
||||
result = await db.execute(q)
|
||||
activities = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
"sport_type": a.sport_type,
|
||||
"start_time": a.start_time,
|
||||
"distance_m": a.distance_m,
|
||||
"duration_s": a.duration_s,
|
||||
}
|
||||
for a in activities
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=RouteOut)
|
||||
async def create_route(
|
||||
body: RouteCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
act_result = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.id == body.activity_id,
|
||||
Activity.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
activity = act_result.scalar_one_or_none()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
|
||||
route = NamedRoute(
|
||||
user_id=current_user.id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
sport_type=body.sport_type or activity.sport_type,
|
||||
reference_polyline=activity.polyline,
|
||||
bounding_box=activity.bounding_box,
|
||||
distance_m=activity.distance_m,
|
||||
auto_detected=False,
|
||||
)
|
||||
db.add(route)
|
||||
await db.flush()
|
||||
activity.named_route_id = route.id
|
||||
await db.commit()
|
||||
await db.refresh(route)
|
||||
return route
|
||||
|
||||
|
||||
@router.get("/{route_id}", response_model=RouteOut)
|
||||
async def get_route(
|
||||
route_id: int,
|
||||
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")
|
||||
return route
|
||||
|
||||
|
||||
@router.get("/{route_id}/activities")
|
||||
async def route_activities(
|
||||
route_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.named_route_id == route_id,
|
||||
Activity.user_id == current_user.id,
|
||||
).order_by(Activity.duration_s)
|
||||
)
|
||||
activities = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
"start_time": a.start_time,
|
||||
"duration_s": a.duration_s,
|
||||
"distance_m": a.distance_m,
|
||||
"avg_heart_rate": a.avg_heart_rate,
|
||||
"avg_speed_ms": a.avg_speed_ms,
|
||||
}
|
||||
for a in 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")
|
||||
async def assign_activity_to_route(
|
||||
route_id: int,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
activity_id = body.get("activity_id")
|
||||
act_result = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.id == activity_id,
|
||||
Activity.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
activity = act_result.scalar_one_or_none()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
activity.named_route_id = route_id
|
||||
await db.commit()
|
||||
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,151 @@
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.workers.tasks import process_activity_file, process_garmin_health_zip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
|
||||
|
||||
def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / upload.filename
|
||||
with open(dest, "wb") as f:
|
||||
shutil.copyfileobj(upload.file, f)
|
||||
return dest
|
||||
|
||||
|
||||
@router.post("/activity")
|
||||
async def upload_activity(
|
||||
file: UploadFile = File(...),
|
||||
background_tasks: BackgroundTasks = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload a single .fit or .gpx activity file."""
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
if suffix not in {".fit", ".gpx"}:
|
||||
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
|
||||
|
||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
|
||||
dest = save_upload(file, dest_dir)
|
||||
|
||||
# Queue processing
|
||||
task = process_activity_file.delay(str(dest), current_user.id, suffix[1:])
|
||||
|
||||
return {"task_id": task.id, "status": "queued", "filename": file.filename}
|
||||
|
||||
|
||||
@router.post("/garmin-export")
|
||||
async def upload_garmin_export(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Upload a full Garmin Connect data export ZIP.
|
||||
Processes all FIT files for activities + wellness data.
|
||||
"""
|
||||
if not file.filename.endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Please upload a .zip Garmin export")
|
||||
|
||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
||||
dest = save_upload(file, dest_dir)
|
||||
|
||||
# Extract and queue all FIT files
|
||||
extract_dir = dest_dir / f"garmin_{dest.stem}"
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
task_ids = []
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
for name in zf.namelist():
|
||||
lower = name.lower()
|
||||
if lower.endswith(".fit"):
|
||||
fit_path = extract_dir / name
|
||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
||||
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
|
||||
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
"activity_tasks": len(task_ids),
|
||||
"task_id": health_task.id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/strava-export")
|
||||
async def upload_strava_export(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files)."""
|
||||
if not file.filename.endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
|
||||
|
||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
||||
dest = save_upload(file, dest_dir)
|
||||
|
||||
extract_dir = dest_dir / f"strava_{dest.stem}"
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
task_ids = []
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
for name in zf.namelist():
|
||||
lower = name.lower()
|
||||
if lower.endswith(".fit") or lower.endswith(".gpx"):
|
||||
file_path = extract_dir / name
|
||||
ext = Path(name).suffix[1:]
|
||||
task = process_activity_file.delay(str(file_path), current_user.id, ext)
|
||||
task_ids.append(task.id)
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
"activity_tasks": len(task_ids),
|
||||
"task_id": task_ids[-1] if task_ids else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/task/{task_id}")
|
||||
async def check_task_status(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Check the status of an upload processing task."""
|
||||
from app.workers.celery_app import celery_app
|
||||
result = celery_app.AsyncResult(task_id)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": result.status,
|
||||
"result": result.result if result.ready() 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"}
|
||||
@@ -0,0 +1,35 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database
|
||||
database_url: str = Field(..., env="DATABASE_URL")
|
||||
# Redis
|
||||
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
|
||||
# Auth
|
||||
secret_key: str = Field(..., env="SECRET_KEY")
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||
# Admin account
|
||||
admin_username: str = Field("admin", env="ADMIN_USERNAME")
|
||||
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_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
|
||||
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
|
||||
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
|
||||
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
|
||||
# Files
|
||||
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
||||
# Environment
|
||||
environment: str = Field("production", env="ENVIRONMENT")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,47 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
# Async engine for FastAPI
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.environment == "development",
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
# Sync engine for Celery workers (Celery + asyncio don't mix well)
|
||||
# Convert async URL to sync: postgresql+asyncpg:// → postgresql+psycopg2://
|
||||
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
||||
sync_engine = create_engine(
|
||||
sync_url,
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
SyncSessionLocal = sessionmaker(sync_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
|
||||
)
|
||||
to_encode["exp"] = expire
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
@@ -0,0 +1,232 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy import text
|
||||
import asyncio
|
||||
|
||||
from app.core.database import engine, AsyncSessionLocal, Base
|
||||
from app.core.config import settings
|
||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Create tables then seed admin, with retries for slow DB startup.
|
||||
|
||||
Multiple uvicorn workers may race here on first start. We tolerate
|
||||
duplicate table errors since they just mean another worker got there first.
|
||||
"""
|
||||
for attempt in range(15):
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
break
|
||||
except Exception as e:
|
||||
msg = str(e).lower()
|
||||
if "already exists" in msg or "duplicate" in msg or "pg_type_typname" in msg:
|
||||
print("Tables already created by another worker - skipping")
|
||||
break
|
||||
if attempt == 14:
|
||||
raise
|
||||
print(f"DB not ready yet (attempt {attempt + 1}/15): {e}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Try TimescaleDB hypertable (non-fatal)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"SELECT create_hypertable('activity_data_points', 'timestamp', "
|
||||
"if_not_exists => TRUE, migrate_data => TRUE)"
|
||||
))
|
||||
except Exception as 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)
|
||||
if not settings.admin_password:
|
||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||
return
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.core.security import hash_password
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == settings.admin_username)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
admin = User(
|
||||
username=settings.admin_username,
|
||||
hashed_password=hash_password(settings.admin_password),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
print(f"Admin user '{settings.admin_username}' created")
|
||||
except Exception as e:
|
||||
msg = str(e).lower()
|
||||
if "duplicate" in msg or "unique" in msg:
|
||||
print("Admin user already exists - skipping seed")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="MileVault",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"] if settings.environment == "development" else [],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
|
||||
app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
|
||||
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
|
||||
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def healthcheck():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,261 @@
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Float, DateTime, Boolean,
|
||||
ForeignKey, Text, JSON, Index, UniqueConstraint, text
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
def now_utc():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||
email = Column(String(256), unique=True, nullable=True)
|
||||
hashed_password = Column(String(256), nullable=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
pocketid_sub = Column(String(256), unique=True, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
# Health profile
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
resting_heart_rate = Column(Integer, nullable=True)
|
||||
birth_year = Column(Integer, 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_issuer = Column(String(512), nullable=True)
|
||||
pocketid_client_id = 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")
|
||||
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
||||
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
|
||||
|
||||
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):
|
||||
"""Manual weight entries separate from health_metrics for easy tracking."""
|
||||
__tablename__ = "weight_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
date = Column(DateTime(timezone=True), nullable=False)
|
||||
weight_kg = Column(Float, nullable=False)
|
||||
body_fat_pct = Column(Float, nullable=True)
|
||||
note = Column(String(256), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_weight_user_date", "user_id", "date"),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="weight_logs")
|
||||
|
||||
|
||||
class Activity(Base):
|
||||
__tablename__ = "activities"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(256), nullable=False)
|
||||
sport_type = Column(String(64), nullable=False)
|
||||
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||
distance_m = Column(Float, nullable=True)
|
||||
duration_s = Column(Float, nullable=True)
|
||||
elevation_gain_m = Column(Float, nullable=True)
|
||||
elevation_loss_m = Column(Float, nullable=True)
|
||||
avg_heart_rate = Column(Float, nullable=True)
|
||||
max_heart_rate = Column(Float, nullable=True)
|
||||
avg_cadence = Column(Float, nullable=True)
|
||||
avg_power = Column(Float, nullable=True)
|
||||
normalized_power = Column(Float, nullable=True)
|
||||
avg_speed_ms = Column(Float, nullable=True)
|
||||
max_speed_ms = Column(Float, nullable=True)
|
||||
avg_temperature_c = Column(Float, nullable=True)
|
||||
calories = Column(Float, nullable=True)
|
||||
training_stress_score = Column(Float, nullable=True)
|
||||
vo2max_estimate = Column(Float, nullable=True)
|
||||
named_route_id = Column(Integer, ForeignKey("named_routes.id", ondelete="SET NULL"), nullable=True)
|
||||
polyline = Column(Text, nullable=True)
|
||||
bounding_box = Column(JSON, nullable=True)
|
||||
source_file = Column(String(512), nullable=True)
|
||||
source_type = Column(String(32), nullable=True)
|
||||
garmin_activity_id = Column(String(64), nullable=True, unique=True)
|
||||
strava_activity_id = Column(String(64), nullable=True, unique=True)
|
||||
hr_zones = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
user = relationship("User", back_populates="activities")
|
||||
data_points = relationship("ActivityDataPoint", back_populates="activity", cascade="all, delete-orphan")
|
||||
named_route = relationship("NamedRoute", back_populates="activities")
|
||||
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ActivityDataPoint(Base):
|
||||
__tablename__ = "activity_data_points"
|
||||
|
||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
|
||||
timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
altitude_m = Column(Float, nullable=True)
|
||||
heart_rate = Column(Float, nullable=True)
|
||||
cadence = Column(Float, nullable=True)
|
||||
speed_ms = Column(Float, nullable=True)
|
||||
power = Column(Float, nullable=True)
|
||||
temperature_c = Column(Float, nullable=True)
|
||||
distance_m = Column(Float, nullable=True)
|
||||
|
||||
activity = relationship("Activity", back_populates="data_points")
|
||||
|
||||
|
||||
class ActivityLap(Base):
|
||||
__tablename__ = "activity_laps"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
|
||||
lap_number = Column(Integer, nullable=False)
|
||||
start_time = Column(DateTime(timezone=True), nullable=True)
|
||||
duration_s = Column(Float, nullable=True)
|
||||
distance_m = Column(Float, nullable=True)
|
||||
avg_heart_rate = Column(Float, nullable=True)
|
||||
avg_cadence = Column(Float, nullable=True)
|
||||
avg_speed_ms = Column(Float, nullable=True)
|
||||
avg_power = Column(Float, nullable=True)
|
||||
|
||||
activity = relationship("Activity", back_populates="laps")
|
||||
|
||||
|
||||
class NamedRoute(Base):
|
||||
__tablename__ = "named_routes"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(256), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
sport_type = Column(String(64), nullable=True)
|
||||
reference_polyline = Column(Text, nullable=True)
|
||||
bounding_box = Column(JSON, nullable=True)
|
||||
distance_m = Column(Float, nullable=True)
|
||||
auto_detected = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
user = relationship("User", back_populates="named_routes")
|
||||
activities = relationship("Activity", back_populates="named_route")
|
||||
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RouteSegment(Base):
|
||||
__tablename__ = "route_segments"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
|
||||
name = Column(String(256), nullable=False)
|
||||
start_distance_m = Column(Float, nullable=False)
|
||||
end_distance_m = Column(Float, nullable=False)
|
||||
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")
|
||||
|
||||
|
||||
class PersonalRecord(Base):
|
||||
__tablename__ = "personal_records"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
|
||||
sport_type = Column(String(64), nullable=False)
|
||||
distance_m = Column(Float, nullable=False)
|
||||
distance_label = Column(String(32), nullable=False)
|
||||
duration_s = Column(Float, nullable=False)
|
||||
achieved_at = Column(DateTime(timezone=True), nullable=False)
|
||||
is_current_record = Column(Boolean, default=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Uniqueness is enforced at runtime by the partial index uq_pr_current_active
|
||||
# (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),
|
||||
)
|
||||
|
||||
|
||||
class HealthMetric(Base):
|
||||
__tablename__ = "health_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
date = Column(DateTime(timezone=True), nullable=False)
|
||||
resting_hr = Column(Float, nullable=True)
|
||||
max_hr_day = Column(Float, nullable=True)
|
||||
avg_hr_day = Column(Float, nullable=True)
|
||||
hrv_status = Column(String(32), nullable=True)
|
||||
hrv_nightly_avg = Column(Float, nullable=True)
|
||||
hrv_5min_high = Column(Float, nullable=True)
|
||||
hrv_5min_low = Column(Float, nullable=True)
|
||||
sleep_duration_s = Column(Float, nullable=True)
|
||||
sleep_deep_s = Column(Float, nullable=True)
|
||||
sleep_light_s = Column(Float, nullable=True)
|
||||
sleep_rem_s = Column(Float, nullable=True)
|
||||
sleep_awake_s = Column(Float, nullable=True)
|
||||
sleep_score = Column(Float, nullable=True)
|
||||
sleep_start = Column(DateTime(timezone=True), nullable=True)
|
||||
sleep_end = Column(DateTime(timezone=True), nullable=True)
|
||||
weight_kg = Column(Float, nullable=True)
|
||||
bmi = Column(Float, nullable=True)
|
||||
body_fat_pct = Column(Float, nullable=True)
|
||||
muscle_mass_kg = Column(Float, nullable=True)
|
||||
vo2max = Column(Float, nullable=True)
|
||||
fitness_age = Column(Integer, nullable=True)
|
||||
training_load = Column(Float, nullable=True)
|
||||
recovery_time_h = Column(Float, nullable=True)
|
||||
avg_stress = Column(Float, nullable=True)
|
||||
steps = Column(Integer, nullable=True)
|
||||
floors_climbed = Column(Integer, nullable=True)
|
||||
active_calories = Column(Float, nullable=True)
|
||||
total_calories = 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__ = (
|
||||
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
||||
Index("ix_health_user_date", "user_id", "date"),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="health_metrics")
|
||||
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
FIT and GPX file parser.
|
||||
Parses FIT files directly using the Garmin SDK but applies manual
|
||||
scale conversion for fields where the SDK doesn't auto-convert.
|
||||
"""
|
||||
import math
|
||||
import struct
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import gpxpy
|
||||
import polyline as polyline_lib
|
||||
from garmin_fit_sdk import Decoder, Stream
|
||||
|
||||
FIT_EPOCH_S = 631065600
|
||||
SEMICIRCLES_TO_DEG = 180.0 / (2 ** 31)
|
||||
|
||||
|
||||
def _semicircles_to_deg(val):
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
result = float(val) * SEMICIRCLES_TO_DEG
|
||||
if -90 <= result <= 90 or -180 <= result <= 180:
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _safe_float(val) -> Optional[float]:
|
||||
try:
|
||||
return float(val) if val is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
return None
|
||||
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)}
|
||||
|
||||
|
||||
def _to_dt(val) -> Optional[datetime]:
|
||||
if val is None:
|
||||
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
|
||||
|
||||
|
||||
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 = []
|
||||
laps = []
|
||||
|
||||
def listener(mesg_num: int, msg: dict):
|
||||
if mesg_num == 18: # session
|
||||
session_data.update(msg)
|
||||
elif mesg_num == 20: # record
|
||||
records.append(msg)
|
||||
elif mesg_num == 19: # lap
|
||||
laps.append(msg)
|
||||
|
||||
stream = Stream.from_file(filepath)
|
||||
decoder = Decoder(stream)
|
||||
decoder.read(
|
||||
apply_scale_and_offset=True,
|
||||
convert_datetimes_to_dates=True,
|
||||
convert_types_to_strings=True,
|
||||
enable_crc_check=False,
|
||||
expand_sub_fields=True,
|
||||
expand_components=True,
|
||||
merge_heart_rates=True,
|
||||
mesg_listener=listener,
|
||||
)
|
||||
|
||||
# The SDK may return field names in camelCase or snake_case depending on version.
|
||||
# Try both. Also handle raw timestamp integers for start_time.
|
||||
def get(d, *keys):
|
||||
for k in keys:
|
||||
v = d.get(k)
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
sport_raw = str(get(session_data, "sport", "Sport") or "generic").lower()
|
||||
sport_map = {
|
||||
"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
|
||||
coords = []
|
||||
for r in records:
|
||||
lat = get_lat(r)
|
||||
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
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
# Normalize data points
|
||||
normalized_points = []
|
||||
for r in records:
|
||||
ts = _to_dt(get(r, "timestamp"))
|
||||
lat = get_lat(r)
|
||||
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({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": _safe_float(lat),
|
||||
"longitude": _safe_float(lon),
|
||||
"altitude_m": _safe_float(altitude),
|
||||
"heart_rate": _safe_float(hr),
|
||||
"cadence": _safe_float(cadence),
|
||||
"speed_ms": _safe_float(speed),
|
||||
"power": _safe_float(power),
|
||||
"temperature_c": _safe_float(temp),
|
||||
"distance_m": _safe_float(distance),
|
||||
})
|
||||
|
||||
# Normalize laps
|
||||
normalized_laps = []
|
||||
for i, lap in enumerate(laps):
|
||||
ls = _to_dt(get(lap, "startTime", "start_time"))
|
||||
lap_dist = _safe_float(get(lap, "totalDistance", "total_distance"))
|
||||
lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time"))
|
||||
normalized_laps.append({
|
||||
"lap_number": i + 1,
|
||||
"start_time": ls.isoformat() if ls else None,
|
||||
"duration_s": lap_dur,
|
||||
"distance_m": lap_dist,
|
||||
"avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")),
|
||||
"avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")),
|
||||
"avg_speed_ms": _sanitize_speed(
|
||||
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")),
|
||||
})
|
||||
|
||||
name = sport_type.title()
|
||||
if start_time:
|
||||
name += " " + start_time.strftime("%Y-%m-%d")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"sport_type": sport_type,
|
||||
"start_time": start_time.isoformat() if start_time else None,
|
||||
"distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")),
|
||||
"duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
|
||||
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
|
||||
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
|
||||
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
|
||||
"max_heart_rate": _safe_float(get(session_data, "maxHeartRate", "max_heart_rate")),
|
||||
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
|
||||
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
|
||||
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
|
||||
"avg_speed_ms": _sanitize_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",
|
||||
"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,
|
||||
"bounding_box": bounding_box,
|
||||
"source_type": "fit",
|
||||
"data_points": normalized_points,
|
||||
"laps": normalized_laps,
|
||||
}
|
||||
|
||||
|
||||
def parse_gpx_file(filepath: str) -> dict:
|
||||
with open(filepath) as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
|
||||
data_points = []
|
||||
track = gpx.tracks[0] if gpx.tracks else None
|
||||
if not track:
|
||||
raise ValueError("No tracks found in GPX file")
|
||||
|
||||
for segment in track.segments:
|
||||
for pt in segment.points:
|
||||
ts = pt.time
|
||||
if ts and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
extensions = {}
|
||||
if pt.extensions:
|
||||
for ext in pt.extensions:
|
||||
for child in ext:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
try:
|
||||
extensions[tag] = float(child.text)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
data_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": pt.latitude, "longitude": pt.longitude,
|
||||
"altitude_m": pt.elevation,
|
||||
"heart_rate": extensions.get("hr"),
|
||||
"cadence": extensions.get("cad"),
|
||||
"speed_ms": extensions.get("speed"),
|
||||
"power": extensions.get("power"),
|
||||
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
|
||||
"distance_m": None,
|
||||
})
|
||||
|
||||
coords = [(p["latitude"], p["longitude"]) for p in data_points if p["latitude"] and p["longitude"]]
|
||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
total_dist = 0.0
|
||||
prev = None
|
||||
for p in data_points:
|
||||
if p["latitude"] and p["longitude"]:
|
||||
if prev:
|
||||
R = 6371000
|
||||
phi1, phi2 = math.radians(prev[0]), math.radians(p["latitude"])
|
||||
dphi = math.radians(p["latitude"] - prev[0])
|
||||
dlam = math.radians(p["longitude"] - prev[1])
|
||||
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
||||
total_dist += 2 * R * math.asin(math.sqrt(a))
|
||||
prev = (p["latitude"], p["longitude"])
|
||||
p["distance_m"] = total_dist
|
||||
|
||||
uphill, downhill = 0.0, 0.0
|
||||
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
||||
for i in range(1, len(alts)):
|
||||
diff = alts[i] - alts[i-1]
|
||||
if diff > 0: uphill += diff
|
||||
else: downhill += abs(diff)
|
||||
|
||||
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
||||
start_time_str = data_points[0]["timestamp"] if data_points 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
|
||||
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
||||
sport = track.type.lower() if track.type else "running"
|
||||
|
||||
return {
|
||||
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
|
||||
"sport_type": sport, "start_time": start_time_str,
|
||||
"distance_m": total_dist, "duration_s": duration,
|
||||
"elevation_gain_m": uphill, "elevation_loss_m": downhill,
|
||||
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
|
||||
"max_heart_rate": max(hrs) if hrs else None,
|
||||
"avg_cadence": None, "avg_power": None, "normalized_power": None,
|
||||
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
|
||||
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
|
||||
"training_stress_score": None, "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:
|
||||
if not user_max_hr or user_max_hr < 100:
|
||||
return {}
|
||||
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
|
||||
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
|
||||
zones = {k: 0 for k in zone_keys}
|
||||
total = 0
|
||||
for p in data_points:
|
||||
hr = p.get("heart_rate")
|
||||
if not hr or hr < 20:
|
||||
continue
|
||||
pct = hr / user_max_hr
|
||||
total += 1
|
||||
for i, key in enumerate(zone_keys):
|
||||
if zone_bounds[i] <= pct < zone_bounds[i+1]:
|
||||
zones[key] += 1
|
||||
break
|
||||
else:
|
||||
zones["z5"] += 1
|
||||
if total:
|
||||
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||
return {}
|
||||
@@ -0,0 +1,582 @@
|
||||
"""
|
||||
Garmin Connect sync helpers.
|
||||
|
||||
authenticate_garmin() returns an authenticated client, refreshing the stored
|
||||
OAuth token when possible and falling back to email/password re-login.
|
||||
|
||||
sync_activities() downloads new FIT files and queues them for processing.
|
||||
sync_wellness() pulls daily stats/sleep/HRV summaries from the JSON API
|
||||
and upserts them into health_metrics.
|
||||
"""
|
||||
import io
|
||||
import zipfile
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Password encryption ─────────────────────────────────────────────────────
|
||||
|
||||
def _fernet():
|
||||
import base64, hashlib
|
||||
from cryptography.fernet import Fernet
|
||||
from app.core.config import settings
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(settings.secret_key.encode()).digest())
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_password(password: str) -> str:
|
||||
return _fernet().encrypt(password.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_password(enc: str) -> str:
|
||||
return _fernet().decrypt(enc.encode()).decode()
|
||||
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str]) -> Tuple:
|
||||
"""
|
||||
Returns (garmin_client, new_token_store_or_None).
|
||||
new_token_store is set only when tokens were refreshed/re-created so the
|
||||
caller can persist them.
|
||||
"""
|
||||
import garminconnect
|
||||
|
||||
# Try stored OAuth token first.
|
||||
# Use garth.loads() directly (always treats the argument as an inline string).
|
||||
# garmin.login(tokenstore=...) dispatches on len>512, treating short tokens as
|
||||
# filesystem paths and raising FileNotFoundError on every token-based auth attempt.
|
||||
# After loads(), set display_name from the embedded profile — required by
|
||||
# get_stats(), get_sleep_data(), and other endpoints that build URLs from it.
|
||||
if token_store:
|
||||
try:
|
||||
garmin = garminconnect.Garmin(
|
||||
email=email, password=decrypt_password(password_enc)
|
||||
)
|
||||
garmin.garth.loads(token_store)
|
||||
garmin.display_name = (garmin.garth.profile or {}).get("displayName", "")
|
||||
return garmin, None
|
||||
except Exception as exc:
|
||||
logger.info("Garmin token invalid (%s), re-authenticating", exc)
|
||||
|
||||
# Full login with email + password
|
||||
garmin = garminconnect.Garmin(email=email, password=decrypt_password(password_enc))
|
||||
garmin.login()
|
||||
return garmin, garmin.garth.dumps()
|
||||
|
||||
|
||||
# ── Activity sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
def sync_activities(garmin, user_id: int, since: Optional[datetime],
|
||||
db, file_store_path: str, lookback_days: int = 30,
|
||||
status_callback=None) -> int:
|
||||
"""
|
||||
List activities from Garmin Connect, skip any already in the DB, download
|
||||
FIT ZIPs for new ones, and queue them for processing.
|
||||
|
||||
lookback_days controls the start date on every sync:
|
||||
-1 → full history back to 2010 on first sync, then incremental (since-1d)
|
||||
N → incremental (since-1d) when since is set; else last N days on first sync
|
||||
Returns the number of new activities queued.
|
||||
"""
|
||||
import time
|
||||
from app.workers.tasks import process_activity_file
|
||||
from app.models.user import Activity
|
||||
from sqlalchemy import select, func
|
||||
|
||||
if lookback_days == -1:
|
||||
# All-time: full pull on first sync, incremental thereafter
|
||||
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1)
|
||||
elif since:
|
||||
# Use whichever is earlier: one day before last sync OR the configured lookback
|
||||
# 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:
|
||||
start_date = date.today() - timedelta(days=max(lookback_days, 1))
|
||||
end_date = date.today()
|
||||
|
||||
try:
|
||||
activities = garmin.get_activities_by_date(
|
||||
start_date.isoformat(), end_date.isoformat()
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to list Garmin activities: %s", exc)
|
||||
return 0
|
||||
|
||||
total = len(activities)
|
||||
if status_callback and total:
|
||||
status_callback(f"Syncing activities: 0/{total} queued")
|
||||
|
||||
queued = 0
|
||||
for act in activities:
|
||||
garmin_id = str(act.get("activityId", "")).strip()
|
||||
if not garmin_id:
|
||||
continue
|
||||
|
||||
# Fast path: already imported via Garmin Connect sync
|
||||
existing = db.execute(
|
||||
select(Activity).where(Activity.garmin_activity_id == garmin_id)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Slow-path dedup: activity imported via bulk export (no garmin_activity_id).
|
||||
# Check by start_time; stamp the ID so future syncs skip it in the fast path.
|
||||
act_start_str = act.get("startTimeLocal") or act.get("startTimeGMT") or ""
|
||||
if act_start_str:
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
act_start = _dt.fromisoformat(act_start_str.replace("Z", "+00:00"))
|
||||
time_match = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
func.date(Activity.start_time) == act_start.date(),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if time_match:
|
||||
if not time_match.garmin_activity_id:
|
||||
time_match.garmin_activity_id = garmin_id
|
||||
db.commit()
|
||||
continue
|
||||
except Exception:
|
||||
pass # couldn't parse time — fall through to download
|
||||
|
||||
# Download original FIT (Garmin wraps it in a ZIP)
|
||||
try:
|
||||
zip_bytes = garmin.download_activity(
|
||||
int(garmin_id),
|
||||
dl_fmt=garmin.ActivityDownloadFormat.ORIGINAL,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to download activity %s: %s", garmin_id, exc)
|
||||
continue
|
||||
|
||||
# Extract the FIT from the ZIP
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
|
||||
if not fit_names:
|
||||
logger.debug("No FIT in ZIP for activity %s", garmin_id)
|
||||
continue
|
||||
fit_data = zf.read(fit_names[0])
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to unzip activity %s: %s", garmin_id, exc)
|
||||
continue
|
||||
|
||||
# Save to disk and queue
|
||||
dest_dir = Path(file_store_path) / str(user_id) / "garmin_connect"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / f"{garmin_id}.fit"
|
||||
dest.write_bytes(fit_data)
|
||||
|
||||
process_activity_file.delay(str(dest), user_id, "fit", garmin_id)
|
||||
queued += 1
|
||||
|
||||
if status_callback and (queued % 5 == 0 or queued == total):
|
||||
status_callback(f"Syncing activities: {queued}/{total} queued")
|
||||
|
||||
# Brief pause to avoid hammering the Garmin API
|
||||
time.sleep(0.5)
|
||||
|
||||
return queued
|
||||
|
||||
|
||||
# ── Wellness sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
|
||||
lookback_days: int = 90, status_callback=None) -> int:
|
||||
"""
|
||||
Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each
|
||||
day in the window and upsert into health_metrics.
|
||||
|
||||
lookback_days controls the window on every sync:
|
||||
-1 → full history back to 2010 on first sync, then incremental (since-1d)
|
||||
N → incremental (since-1d) when since is set; else last N days on first sync
|
||||
Returns the number of days upserted.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
if lookback_days == -1:
|
||||
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1)
|
||||
elif since:
|
||||
# Use whichever is earlier: one day before last sync OR the configured lookback
|
||||
# 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:
|
||||
start_date = date.today() - timedelta(days=max(lookback_days, 1))
|
||||
days = (date.today() - start_date).days + 1
|
||||
processed = 0
|
||||
|
||||
import time as _time
|
||||
import json as _json
|
||||
total_days = max(days, 1)
|
||||
if status_callback:
|
||||
status_callback(f"Syncing wellness: 0/{total_days} days")
|
||||
for i in range(total_days):
|
||||
day = start_date + timedelta(days=i)
|
||||
if status_callback and (i % 5 == 0 or i == total_days - 1):
|
||||
status_callback(f"Syncing wellness: {i + 1}/{total_days} days")
|
||||
day_str = day.isoformat()
|
||||
|
||||
stats = _safe(garmin.get_stats, day_str)
|
||||
sleep_data = _safe(garmin.get_sleep_data, day_str)
|
||||
hrv_data = _safe(garmin.get_hrv_data, day_str)
|
||||
# Intraday HR (requires display_name; skip gracefully if absent)
|
||||
hr_raw = _safe(garmin.get_heart_rates, day_str) if garmin.display_name else None
|
||||
bc_data = _safe(garmin.get_body_composition, day_str, day_str)
|
||||
bb_raw = _safe(garmin.get_body_battery, day_str, day_str)
|
||||
_time.sleep(0.25) # avoid hammering Garmin's wellness API
|
||||
|
||||
row = _parse_day(stats, sleep_data, hrv_data)
|
||||
|
||||
# Weight + body composition from weight service (more reliable than stats)
|
||||
if bc_data:
|
||||
entries = (bc_data.get("dateWeightList")
|
||||
or bc_data.get("allWeightMetrics")
|
||||
or bc_data.get("weightList") or [])
|
||||
if entries:
|
||||
e = entries[0]
|
||||
bw = e.get("weight")
|
||||
if bw and float(bw) > 0:
|
||||
bwf = float(bw)
|
||||
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2))
|
||||
if e.get("bmi"):
|
||||
_set(row, "bmi", float(e["bmi"]))
|
||||
if e.get("bodyFat"):
|
||||
_set(row, "body_fat_pct", float(e["bodyFat"]))
|
||||
mm = e.get("muscleMass")
|
||||
if mm and float(mm) > 0:
|
||||
mmf = float(mm)
|
||||
_set(row, "muscle_mass_kg", round(mmf / 1000 if mmf > 300 else mmf, 2))
|
||||
|
||||
# Weight from daily stats as fallback (present when Garmin scale is used)
|
||||
if stats and "weight_kg" not in row:
|
||||
bw = stats.get("bodyWeight")
|
||||
if bw and float(bw) > 0:
|
||||
bwf = float(bw)
|
||||
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2))
|
||||
|
||||
# Body battery — store summary + fine-grained timeline
|
||||
bb = None
|
||||
if bb_raw:
|
||||
bb = _parse_body_battery(bb_raw, day_str)
|
||||
if bb:
|
||||
row["body_battery"] = _json.dumps(bb)
|
||||
|
||||
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs + compute daily averages
|
||||
intraday = None
|
||||
if hr_raw:
|
||||
raw_vals = hr_raw.get("heartRateValues") or []
|
||||
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
|
||||
if intraday:
|
||||
row["intraday_hr"] = intraday
|
||||
hr_vals = [v for _, v in intraday if v > 0]
|
||||
if hr_vals:
|
||||
row["avg_hr_day"] = round(sum(hr_vals) / len(hr_vals), 1)
|
||||
row["max_hr_day"] = float(max(hr_vals))
|
||||
|
||||
# High-resolution body battery derived from BB checkpoints + intraday HR
|
||||
if bb and intraday:
|
||||
hires = _compute_body_battery_hires(bb.get("values") or [], intraday)
|
||||
if hires:
|
||||
row["body_battery_hires"] = _json.dumps(hires)
|
||||
|
||||
if not row:
|
||||
continue
|
||||
|
||||
# psycopg2 treats Python lists/dicts as PG arrays/hstore; serialize JSON
|
||||
# columns as strings so psycopg2 passes them correctly to json/jsonb columns.
|
||||
if "intraday_hr" in row and not isinstance(row["intraday_hr"], str):
|
||||
row["intraday_hr"] = _json.dumps(row["intraday_hr"])
|
||||
if "body_battery" in row and not isinstance(row["body_battery"], str):
|
||||
row["body_battery"] = _json.dumps(row["body_battery"])
|
||||
|
||||
cols = list(row.keys())
|
||||
col_sql = ", ".join(cols)
|
||||
val_sql = ", ".join(f":{c}" for c in cols)
|
||||
upd_sql = ", ".join(
|
||||
# total_calories uses GREATEST so multiple sources don't downgrade
|
||||
f"{c} = GREATEST(EXCLUDED.{c}, health_metrics.{c})"
|
||||
if c == "total_calories" else
|
||||
f"{c} = COALESCE(EXCLUDED.{c}, health_metrics.{c})"
|
||||
for c in cols
|
||||
)
|
||||
|
||||
params = {"user_id": user_id, "day": day.isoformat()}
|
||||
params.update(row)
|
||||
|
||||
try:
|
||||
db.execute(text(f"""
|
||||
INSERT INTO health_metrics (user_id, date, {col_sql})
|
||||
VALUES (:user_id, :day, {val_sql})
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET {upd_sql}
|
||||
"""), params)
|
||||
db.commit()
|
||||
processed += 1
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to upsert health_metrics for %s: %s", day_str, exc)
|
||||
db.rollback()
|
||||
|
||||
# Fetch historical VO2 max across the full sync window via maxmet/daily range query
|
||||
today_str = date.today().isoformat()
|
||||
fa_data = _safe(garmin.get_fitnessage_data, today_str)
|
||||
fa_age = None
|
||||
if fa_data:
|
||||
fa_age = fa_data.get("fitnessAge") or fa_data.get("achievableFitnessAge")
|
||||
|
||||
mm_entries = []
|
||||
try:
|
||||
mm_raw = garmin.connectapi(
|
||||
f"/metrics-service/metrics/maxmet/daily/{start_date.isoformat()}/{today_str}"
|
||||
)
|
||||
logger.info("maxmet range query returned type=%s len=%s",
|
||||
type(mm_raw).__name__,
|
||||
len(mm_raw) if isinstance(mm_raw, (list, dict)) else "n/a")
|
||||
if isinstance(mm_raw, list):
|
||||
mm_entries = mm_raw
|
||||
except Exception as exc:
|
||||
logger.info("maxmet history fetch failed: %s", exc)
|
||||
|
||||
# Each entry has the vo2max data nested under entry["generic"]
|
||||
def _extract_generic(entry):
|
||||
return (entry.get("generic") or {}) if isinstance(entry, dict) else {}
|
||||
|
||||
valid_from_range = any(
|
||||
(_extract_generic(e).get("vo2MaxPreciseValue") or _extract_generic(e).get("vo2MaxValue") or 0)
|
||||
for e in mm_entries
|
||||
)
|
||||
|
||||
# Always fall back to training_status when the range query had no valid data
|
||||
if not valid_from_range:
|
||||
ts_data = _safe(garmin.get_training_status, today_str)
|
||||
generic = ((ts_data or {}).get("mostRecentVO2Max") or {}).get("generic") or {}
|
||||
v = generic.get("vo2MaxPreciseValue") or generic.get("vo2MaxValue")
|
||||
logger.info("training_status vo2max=%s at %s", v, generic.get("calendarDate"))
|
||||
if v and float(v) > 0:
|
||||
mm_entries = [{"generic": {"calendarDate": generic.get("calendarDate") or today_str,
|
||||
"vo2MaxPreciseValue": float(v)}}]
|
||||
|
||||
stored = 0
|
||||
for entry in mm_entries:
|
||||
generic = _extract_generic(entry)
|
||||
v = generic.get("vo2MaxPreciseValue") or generic.get("vo2MaxValue")
|
||||
if not v or float(v) <= 0:
|
||||
continue
|
||||
entry_date = generic.get("calendarDate") or today_str
|
||||
try:
|
||||
fa_row = {"vo2max": float(v)}
|
||||
if fa_age and entry_date == today_str:
|
||||
fa_row["fitness_age"] = int(fa_age)
|
||||
fa_cols = list(fa_row.keys())
|
||||
db.execute(text(f"""
|
||||
INSERT INTO health_metrics (user_id, date, {", ".join(fa_cols)})
|
||||
VALUES (:user_id, :day, {", ".join(f":{c}" for c in fa_cols)})
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||
{", ".join(f"{c} = EXCLUDED.{c}" for c in fa_cols)}
|
||||
"""), {"user_id": user_id, "day": entry_date, **fa_row})
|
||||
db.commit()
|
||||
stored += 1
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to upsert VO2 max for %s: %s", entry_date, exc)
|
||||
db.rollback()
|
||||
|
||||
logger.info("VO2 max: stored=%d from range_valid=%s", stored, valid_from_range)
|
||||
|
||||
return processed
|
||||
|
||||
|
||||
def _parse_body_battery(bb_response, day_str: str):
|
||||
"""Parse get_body_battery() response for a single day into a compact dict."""
|
||||
if not bb_response:
|
||||
return None
|
||||
entry = next((e for e in bb_response if e.get("date") == day_str), None)
|
||||
if not entry and bb_response:
|
||||
entry = bb_response[0]
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
charged = entry.get("charged")
|
||||
drained = entry.get("drained")
|
||||
start_lvl = entry.get("startValue")
|
||||
end_lvl = entry.get("endValue")
|
||||
|
||||
# Fine-grained timeline: [[ts_ms, level, type_code, stress], ...]
|
||||
# type_code: 0=REST, 1=ACTIVE, 2=SLEEP, 3=STRESS, 4=UNMEASURABLE
|
||||
values = entry.get("bodyBatteryValuesArray") or []
|
||||
|
||||
if not values:
|
||||
# Fall back to bodyBatteryStatList (segment-level data)
|
||||
type_map = {"REST": 0, "ACTIVE": 1, "SLEEP": 2, "STRESS": 3, "UNMEASURABLE": 4}
|
||||
for seg in (entry.get("bodyBatteryStatList") or []):
|
||||
ts_str = seg.get("startTimestampGMT") or seg.get("startTimestampLocal")
|
||||
if ts_str:
|
||||
try:
|
||||
from datetime import datetime as _dt, timezone as _tz
|
||||
ts = _dt.fromisoformat(ts_str.rstrip("Z")).replace(tzinfo=_tz.utc)
|
||||
type_code = type_map.get(seg.get("activityType", "UNMEASURABLE"), 4)
|
||||
values.append([int(ts.timestamp() * 1000),
|
||||
int(seg.get("bodyBatteryLevel") or 0),
|
||||
type_code,
|
||||
int(seg.get("stressLevel") or -1)])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if charged is None and end_lvl is None and not values:
|
||||
return None
|
||||
|
||||
return {
|
||||
"charged": charged,
|
||||
"drained": drained,
|
||||
"start_level": start_lvl,
|
||||
"end_level": end_lvl,
|
||||
"values": values, # stripped from list-API, returned in intraday endpoint
|
||||
}
|
||||
|
||||
|
||||
def _compute_body_battery_hires(bb_values, intraday_hr):
|
||||
"""
|
||||
Produce a higher-resolution body battery series by interpolating between
|
||||
sparse BB checkpoints using intraday HR as a proxy for effort.
|
||||
|
||||
During drain segments (BB falling) the drain is distributed proportionally
|
||||
to how much each HR reading exceeds the day's median — peaks spend battery
|
||||
faster than valleys. During recovery segments (BB rising) recovery is
|
||||
spread uniformly over time.
|
||||
|
||||
Returns [[ts_ms, level], ...] at the granularity of intraday HR, or None
|
||||
if inputs are insufficient.
|
||||
"""
|
||||
if not bb_values or not intraday_hr or len(bb_values) < 2:
|
||||
return None
|
||||
|
||||
# Drop entries with None timestamp or level — raw API data can have gaps
|
||||
bb = sorted([v for v in bb_values if v[0] is not None and v[1] is not None],
|
||||
key=lambda x: x[0])
|
||||
if len(bb) < 2:
|
||||
return None
|
||||
hr = sorted(intraday_hr, key=lambda x: x[0])
|
||||
|
||||
hr_vals = [bpm for _, bpm in hr if bpm is not None and bpm > 0]
|
||||
if not hr_vals:
|
||||
return None
|
||||
|
||||
hr_median = sorted(hr_vals)[len(hr_vals) // 2]
|
||||
|
||||
result = []
|
||||
for i in range(len(bb) - 1):
|
||||
t1, L1 = bb[i][0], bb[i][1]
|
||||
t2, L2 = bb[i + 1][0], bb[i + 1][1]
|
||||
delta = L2 - L1
|
||||
|
||||
seg_hr = [(ts, bpm) for ts, bpm in hr if t1 <= ts <= t2 and bpm is not None]
|
||||
result.append([t1, round(float(L1), 1)])
|
||||
|
||||
if not seg_hr or abs(delta) < 1:
|
||||
continue
|
||||
|
||||
if delta < 0:
|
||||
# Drain: weight each reading by HR above median
|
||||
efforts = [max(0.0, bpm - hr_median) for _, bpm in seg_hr]
|
||||
total = sum(efforts) or 1.0
|
||||
cumul = 0.0
|
||||
for j, (ts, bpm) in enumerate(seg_hr):
|
||||
cumul += efforts[j] * delta / total
|
||||
level = max(0.0, min(100.0, L1 + cumul))
|
||||
result.append([ts, round(level, 1)])
|
||||
else:
|
||||
# Recovery: linear over time
|
||||
span = max(1, t2 - t1)
|
||||
for ts, _ in seg_hr:
|
||||
frac = (ts - t1) / span
|
||||
level = max(0.0, min(100.0, L1 + delta * frac))
|
||||
result.append([ts, round(level, 1)])
|
||||
|
||||
result.append([bb[-1][0], round(float(bb[-1][1]), 1)])
|
||||
|
||||
# Deduplicate and sort
|
||||
seen, out = set(), []
|
||||
for item in sorted(result, key=lambda x: x[0]):
|
||||
if item[0] not in seen:
|
||||
seen.add(item[0])
|
||||
out.append(item)
|
||||
|
||||
return out if len(out) > 4 else None
|
||||
|
||||
|
||||
def _safe(fn, *args):
|
||||
try:
|
||||
return fn(*args)
|
||||
except Exception as exc:
|
||||
logger.debug("%s(%s) skipped: %s", fn.__name__, args, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
||||
row = {}
|
||||
|
||||
if stats:
|
||||
_set(row, "resting_hr", stats.get("restingHeartRate"))
|
||||
# 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, "steps", stats.get("totalSteps"))
|
||||
_set(row, "floors_climbed", stats.get("floorsAscended"))
|
||||
_set(row, "avg_stress", stats.get("averageStressLevel"))
|
||||
active = stats.get("activeKilocalories")
|
||||
bmr = stats.get("bmrKilocalories")
|
||||
_set(row, "active_calories", active)
|
||||
if active and bmr:
|
||||
_set(row, "total_calories", float(active) + float(bmr))
|
||||
|
||||
if sleep_data:
|
||||
dto = sleep_data.get("dailySleepDTO") or sleep_data
|
||||
_set(row, "sleep_duration_s", dto.get("sleepTimeSeconds"))
|
||||
_set(row, "sleep_deep_s", dto.get("deepSleepSeconds"))
|
||||
_set(row, "sleep_light_s", dto.get("lightSleepSeconds"))
|
||||
_set(row, "sleep_rem_s", dto.get("remSleepSeconds"))
|
||||
_set(row, "sleep_awake_s", dto.get("awakeSleepSeconds"))
|
||||
|
||||
# Timestamps are milliseconds since epoch in local time
|
||||
for key, col in (("sleepStartTimestampLocal", "sleep_start"),
|
||||
("sleepEndTimestampLocal", "sleep_end")):
|
||||
ms = dto.get(key)
|
||||
if ms:
|
||||
_set(row, col, datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat())
|
||||
|
||||
# SpO2
|
||||
spo2 = dto.get("averageSpO2Value")
|
||||
if spo2 and 50 < float(spo2) <= 100:
|
||||
row["spo2_avg"] = float(spo2)
|
||||
|
||||
# Sleep score — structure varies across firmware
|
||||
scores = sleep_data.get("sleepScores") or sleep_data.get("sleepScore")
|
||||
if isinstance(scores, dict):
|
||||
overall = scores.get("overall") or scores.get("qualityScore")
|
||||
if isinstance(overall, dict):
|
||||
_set(row, "sleep_score", overall.get("value"))
|
||||
else:
|
||||
_set(row, "sleep_score", overall)
|
||||
elif isinstance(scores, (int, float)):
|
||||
row["sleep_score"] = scores
|
||||
|
||||
if hrv_data:
|
||||
summary = hrv_data.get("hrvSummary") or hrv_data
|
||||
_set(row, "hrv_nightly_avg", summary.get("lastNight") or summary.get("lastNightAvg"))
|
||||
_set(row, "hrv_5min_high", summary.get("lastNight5MinHigh"))
|
||||
status = summary.get("status")
|
||||
if status:
|
||||
row["hrv_status"] = str(status).lower()
|
||||
|
||||
return row
|
||||
|
||||
|
||||
def _set(d: dict, key: str, val):
|
||||
if val is not None:
|
||||
d[key] = val
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Route matching: identifies when multiple activities were on the same route.
|
||||
Uses a bounding-box pre-filter + dynamic time warping (DTW) for GPS track similarity.
|
||||
"""
|
||||
import math
|
||||
from typing import Optional
|
||||
import polyline as polyline_lib
|
||||
import numpy as np
|
||||
|
||||
|
||||
def decode_polyline_to_coords(encoded: str) -> list[tuple[float, float]]:
|
||||
return polyline_lib.decode(encoded)
|
||||
|
||||
|
||||
def bounding_boxes_overlap(bb1: dict, bb2: dict, tolerance_deg: float = 0.005) -> bool:
|
||||
"""Quick check: do two bounding boxes overlap (with a tolerance margin)?"""
|
||||
return (
|
||||
bb1["min_lat"] - tolerance_deg <= bb2["max_lat"] + tolerance_deg and
|
||||
bb1["max_lat"] + tolerance_deg >= bb2["min_lat"] - tolerance_deg and
|
||||
bb1["min_lon"] - tolerance_deg <= bb2["max_lon"] + tolerance_deg and
|
||||
bb1["max_lon"] + tolerance_deg >= bb2["min_lon"] - tolerance_deg
|
||||
)
|
||||
|
||||
|
||||
def sample_coords(coords: list[tuple], n: int = 100) -> list[tuple]:
|
||||
"""Downsample a track to n evenly-spaced points for DTW efficiency."""
|
||||
if len(coords) <= n:
|
||||
return coords
|
||||
indices = [int(i * (len(coords) - 1) / (n - 1)) for i in range(n)]
|
||||
return [coords[i] for i in indices]
|
||||
|
||||
|
||||
def dtw_distance(track1: list[tuple], track2: list[tuple]) -> float:
|
||||
"""
|
||||
Compute DTW distance between two GPS tracks.
|
||||
Each point is (lat, lon). Returns average distance in metres per matched pair.
|
||||
"""
|
||||
n, m = len(track1), len(track2)
|
||||
dtw = np.full((n + 1, m + 1), np.inf)
|
||||
dtw[0][0] = 0.0
|
||||
|
||||
for i in range(1, n + 1):
|
||||
for j in range(1, m + 1):
|
||||
cost = haversine_m(track1[i-1], track2[j-1])
|
||||
dtw[i][j] = cost + min(dtw[i-1][j], dtw[i][j-1], dtw[i-1][j-1])
|
||||
|
||||
return dtw[n][m] / max(n, m)
|
||||
|
||||
|
||||
def haversine_m(p1: tuple, p2: tuple) -> float:
|
||||
R = 6371000
|
||||
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
|
||||
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def routes_are_similar(
|
||||
poly1: str,
|
||||
poly2: str,
|
||||
bb1: Optional[dict],
|
||||
bb2: Optional[dict],
|
||||
dtw_threshold_m: float = 80.0,
|
||||
dist1: Optional[float] = None,
|
||||
dist2: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if two activities are on sufficiently similar routes.
|
||||
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 not bounding_boxes_overlap(bb1, bb2):
|
||||
return False
|
||||
|
||||
try:
|
||||
coords1 = sample_coords(decode_polyline_to_coords(poly1), 60)
|
||||
coords2 = sample_coords(decode_polyline_to_coords(poly2), 60)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not coords1 or not coords2:
|
||||
return False
|
||||
|
||||
dist = dtw_distance(coords1, coords2)
|
||||
return dist < dtw_threshold_m
|
||||
|
||||
|
||||
def find_segment_times(
|
||||
data_points: list[dict],
|
||||
start_dist_m: float,
|
||||
end_dist_m: float,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Given activity data points (with cumulative distance_m),
|
||||
find the time to traverse from start_dist_m to end_dist_m.
|
||||
Returns duration in seconds, or None if not found.
|
||||
"""
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
for p in data_points:
|
||||
dist = p.get("distance_m")
|
||||
ts = p.get("timestamp")
|
||||
if dist is None or ts is None:
|
||||
continue
|
||||
|
||||
if start_time is None and dist >= start_dist_m:
|
||||
start_time = ts
|
||||
|
||||
if start_time is not None and dist >= end_dist_m:
|
||||
end_time = ts
|
||||
break
|
||||
|
||||
if start_time and end_time:
|
||||
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(
|
||||
data_points: list[dict],
|
||||
target_distance_m: float,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Find the best (fastest) time over any target_distance_m window within an activity.
|
||||
E.g. fastest 1km split in a 10km run.
|
||||
Returns duration in seconds.
|
||||
"""
|
||||
points_with_dist = [
|
||||
p for p in data_points
|
||||
if p.get("distance_m") is not None and p.get("timestamp") is not None
|
||||
]
|
||||
|
||||
if not points_with_dist:
|
||||
return None
|
||||
|
||||
best = None
|
||||
j = 0
|
||||
|
||||
for i, start_p in enumerate(points_with_dist):
|
||||
start_dist = start_p["distance_m"]
|
||||
start_ts = start_p["timestamp"]
|
||||
|
||||
# Advance j until distance covered >= target
|
||||
while j < len(points_with_dist):
|
||||
end_p = points_with_dist[j]
|
||||
covered = end_p["distance_m"] - start_dist
|
||||
if covered >= target_distance_m:
|
||||
from datetime import datetime
|
||||
t1 = datetime.fromisoformat(start_ts) if isinstance(start_ts, str) else start_ts
|
||||
t2 = datetime.fromisoformat(end_p["timestamp"]) if isinstance(end_p["timestamp"], str) else end_p["timestamp"]
|
||||
duration = (t2 - t1).total_seconds()
|
||||
if best is None or duration < best:
|
||||
best = duration
|
||||
break
|
||||
j += 1
|
||||
|
||||
if j >= len(points_with_dist):
|
||||
break
|
||||
|
||||
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 = [
|
||||
(400, "400m"),
|
||||
(800, "800m"),
|
||||
(1000, "1k"),
|
||||
(1609.34, "1 mile"),
|
||||
(3000, "3k"),
|
||||
(5000, "5k"),
|
||||
(10000, "10k"),
|
||||
(21097.5, "Half marathon"),
|
||||
(42195, "Marathon"),
|
||||
(50000, "50k"),
|
||||
(100000, "100k"),
|
||||
]
|
||||
|
||||
|
||||
def compute_best_splits(data_points: list[dict], total_distance_m: float) -> dict[str, float]:
|
||||
"""Compute best split times for all standard distances that fit within the activity."""
|
||||
results = {}
|
||||
for dist_m, label in STANDARD_DISTANCES:
|
||||
if total_distance_m >= dist_m * 0.95: # allow 5% tolerance
|
||||
best = find_best_split_time(data_points, dist_m)
|
||||
if best:
|
||||
results[label] = best
|
||||
return results
|
||||
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Sleep stages: message 275 (modern) or 269 (older) each carry a start timestamp
|
||||
and a stage name. Duration of each stage = gap to the next stage's timestamp.
|
||||
The sleep session stop time (from event message 21, event_type='stop') closes
|
||||
the last stage.
|
||||
"""
|
||||
from datetime import datetime, timezone, date
|
||||
from typing import Optional
|
||||
from garmin_fit_sdk import Decoder, Stream
|
||||
|
||||
|
||||
FIT_EPOCH_S = 631065600
|
||||
SLEEP_LEVEL_MAP = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
|
||||
|
||||
|
||||
def _fit_ts(raw) -> Optional[datetime]:
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
s = int(raw)
|
||||
if s <= 0 or s == 0xFFFFFFFF:
|
||||
return None
|
||||
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_date(val) -> Optional[date]:
|
||||
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:
|
||||
"""
|
||||
Parse a Garmin wellness/monitoring FIT file.
|
||||
Returns {"days": {date: metrics_dict}, "error": str|None}
|
||||
"""
|
||||
daily = {}
|
||||
last_date_seen = [None]
|
||||
|
||||
def ensure_day(d: date) -> dict:
|
||||
if d not in daily:
|
||||
daily[d] = {
|
||||
"heart_rates": [],
|
||||
"stress_values": [],
|
||||
"spo2_readings": [],
|
||||
# Each entry: (datetime, level_int) — duration computed from gaps
|
||||
"sleep_epochs": [],
|
||||
"sleep_start": None,
|
||||
"sleep_end": None,
|
||||
"steps": None,
|
||||
"floors_climbed": None,
|
||||
"active_calories": None,
|
||||
"bmr": None,
|
||||
"resting_hr": None,
|
||||
"hrv_nightly_avg": None,
|
||||
"hrv_5min_high": None,
|
||||
"hrv_status": None,
|
||||
"sleep_score": None,
|
||||
}
|
||||
return daily[d]
|
||||
|
||||
def _add_sleep_epoch(ts: datetime, level_raw):
|
||||
d = _to_date(ts)
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
if isinstance(level_raw, str):
|
||||
level = SLEEP_LEVEL_MAP.get(level_raw.lower())
|
||||
else:
|
||||
level = level_raw
|
||||
if level is not None:
|
||||
ensure_day(d)["sleep_epochs"].append((ts, int(level)))
|
||||
|
||||
def listener(mesg_num: int, msg: dict):
|
||||
|
||||
# ── monitoring_info (147) - older firmware ─────────────────────────
|
||||
if mesg_num == 147:
|
||||
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:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
hr = msg.get("heart_rate")
|
||||
if hr and 20 < hr < 250:
|
||||
entry["heart_rates"].append(int(hr))
|
||||
steps = msg.get("steps") or msg.get("cycles")
|
||||
if steps and steps > 0:
|
||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||
stress = msg.get("stress_level_value")
|
||||
if stress is not None and stress >= 0:
|
||||
entry["stress_values"].append(int(stress))
|
||||
|
||||
# ── monitoring (55) - modern, per-interval running totals ──────────
|
||||
elif mesg_num == 55:
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
hr = msg.get("heart_rate")
|
||||
if hr and 20 < hr < 250:
|
||||
entry["heart_rates"].append(int(hr))
|
||||
steps = msg.get("steps")
|
||||
if steps and steps > 0:
|
||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||
active_cal = msg.get("active_calories")
|
||||
if active_cal and active_cal > 0:
|
||||
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:
|
||||
entry["hrv_status"] = str(status)
|
||||
|
||||
# ── message 275 - sleep epochs (modern) or HRV (older firmware) ───
|
||||
elif mesg_num == 275:
|
||||
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:
|
||||
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")
|
||||
if stress is not None and stress >= 0:
|
||||
ensure_day(d)["stress_values"].append(int(stress))
|
||||
|
||||
# ── spo2_data (258) ────────────────────────────────────────────────
|
||||
elif mesg_num == 258:
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
|
||||
if spo2 and 50 < spo2 <= 100:
|
||||
ensure_day(d)["spo2_readings"].append(float(spo2))
|
||||
|
||||
# ── per-minute stress + HR (227) proprietary ───────────────────────
|
||||
elif mesg_num == 227:
|
||||
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
hr_raw = msg.get(2)
|
||||
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
|
||||
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))
|
||||
|
||||
# ── daily resting HR (211) proprietary ─────────────────────────────
|
||||
elif mesg_num == 211:
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
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:
|
||||
entry["resting_hr"] = int(rhr)
|
||||
|
||||
try:
|
||||
stream = Stream.from_file(file_path)
|
||||
decoder = Decoder(stream)
|
||||
messages, errors = decoder.read(
|
||||
apply_scale_and_offset=True,
|
||||
convert_datetimes_to_dates=True,
|
||||
convert_types_to_strings=True,
|
||||
enable_crc_check=False,
|
||||
expand_sub_fields=True,
|
||||
expand_components=True,
|
||||
merge_heart_rates=False,
|
||||
mesg_listener=listener,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e), "days": {}}
|
||||
|
||||
result = {}
|
||||
for day_date, data in daily.items():
|
||||
hrs = data.pop("heart_rates", [])
|
||||
stresses = data.pop("stress_values", [])
|
||||
spo2s = data.pop("spo2_readings", [])
|
||||
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
|
||||
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
|
||||
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
|
||||
|
||||
# Compute sleep stage durations from epoch timestamps
|
||||
if sleep_epochs:
|
||||
epochs_sorted = sorted(sleep_epochs, key=lambda x: x[0])
|
||||
level_secs = {1: 0, 2: 0, 3: 0, 4: 0} # awake, light, deep, rem
|
||||
for i, (ts, level) in enumerate(epochs_sorted):
|
||||
if i + 1 < len(epochs_sorted):
|
||||
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:
|
||||
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] = {
|
||||
"resting_hr": data.get("resting_hr"),
|
||||
"avg_hr_day": avg_hr,
|
||||
"max_hr_day": max_hr,
|
||||
"avg_stress": avg_stress,
|
||||
"spo2_avg": spo2_avg,
|
||||
"hrv_nightly_avg": data.get("hrv_nightly_avg"),
|
||||
"hrv_5min_high": data.get("hrv_5min_high"),
|
||||
"hrv_status": data.get("hrv_status"),
|
||||
"steps": data.get("steps"),
|
||||
"floors_climbed": data.get("floors_climbed"),
|
||||
"active_calories": active_cal,
|
||||
"total_calories": total_cal,
|
||||
"sleep_duration_s": sleep_duration_s,
|
||||
"sleep_deep_s": sleep_deep_s,
|
||||
"sleep_light_s": sleep_light_s,
|
||||
"sleep_rem_s": sleep_rem_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}
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Celery entry point. Re-exports celery_app from tasks so the worker
|
||||
can be started with: celery -A app.workers.celery_app worker
|
||||
"""
|
||||
from app.workers.tasks import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
@@ -0,0 +1,625 @@
|
||||
"""
|
||||
Background tasks: activity ingestion, route matching, PR calculation.
|
||||
|
||||
Uses synchronous SQLAlchemy because Celery's prefork model doesn't play
|
||||
well with asyncio - each worker process needs its own connection pool,
|
||||
and async pools don't survive process forks.
|
||||
"""
|
||||
from celery import Celery
|
||||
from app.core.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"milevault",
|
||||
broker=settings.redis_url,
|
||||
backend=settings.redis_url,
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
result_serializer="json",
|
||||
accept_content=["json"],
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
beat_schedule={
|
||||
"sync-garmin-connect": {
|
||||
"task": "sync_all_garmin_connect",
|
||||
"schedule": 1800.0, # every 30 minutes
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
WELLNESS_SUFFIXES = (
|
||||
"_METRICS.fit",
|
||||
"_WELLNESS.fit",
|
||||
"_SLEEP.fit",
|
||||
"_SLEEP_DATA.fit",
|
||||
"_STRESS.fit",
|
||||
"_SPO2.fit",
|
||||
"_HRV.fit",
|
||||
"_HRV_STATUS.fit",
|
||||
"_MONITORING.fit",
|
||||
"_MONITORING_B.fit",
|
||||
"_RESPIRATION.fit",
|
||||
"_PULSE_OX.fit",
|
||||
)
|
||||
|
||||
|
||||
def is_wellness_file(file_path: str) -> bool:
|
||||
name = file_path.upper()
|
||||
return any(name.endswith(s.upper()) for s in WELLNESS_SUFFIXES)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="process_activity_file")
|
||||
def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
garmin_activity_id: str = None):
|
||||
"""Parse a FIT/GPX file. Routes wellness files to health parser."""
|
||||
|
||||
if is_wellness_file(file_path):
|
||||
parse_wellness_fit.delay(file_path, user_id)
|
||||
return {"status": "routed_to_wellness", "file": file_path}
|
||||
|
||||
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
||||
from sqlalchemy import select, func
|
||||
from datetime import datetime
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"step": "parsing"})
|
||||
|
||||
try:
|
||||
if source_type == "fit" or file_path.endswith(".fit"):
|
||||
parsed = parse_fit_file(file_path)
|
||||
else:
|
||||
parsed = parse_gpx_file(file_path)
|
||||
except Exception as e:
|
||||
raise self.retry(exc=e, countdown=10, max_retries=3)
|
||||
|
||||
if not parsed.get("start_time"):
|
||||
return {"status": "skipped", "reason": "no start_time", "file": file_path}
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||
|
||||
# Deduplicate: same user + sport_type + start_time within ±60s
|
||||
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
|
||||
user_obj = db.execute(
|
||||
select(UserModel).where(UserModel.id == user_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
user_max_hr = None
|
||||
if user_obj:
|
||||
user_max_hr = user_obj.max_heart_rate
|
||||
if not user_max_hr and user_obj.birth_year:
|
||||
from datetime import date as _date
|
||||
age = _date.today().year - user_obj.birth_year
|
||||
user_max_hr = 220 - age
|
||||
if not user_max_hr:
|
||||
user_max_hr = parsed.get("max_heart_rate") or 190
|
||||
|
||||
hr_zones = calculate_hr_zones(parsed.get("data_points", []), user_max_hr)
|
||||
|
||||
activity = Activity(
|
||||
user_id=user_id,
|
||||
name=parsed["name"],
|
||||
sport_type=parsed["sport_type"],
|
||||
garmin_activity_id=garmin_activity_id,
|
||||
start_time=start_time,
|
||||
distance_m=parsed.get("distance_m"),
|
||||
duration_s=parsed.get("duration_s"),
|
||||
elevation_gain_m=parsed.get("elevation_gain_m"),
|
||||
elevation_loss_m=parsed.get("elevation_loss_m"),
|
||||
avg_heart_rate=parsed.get("avg_heart_rate"),
|
||||
max_heart_rate=parsed.get("max_heart_rate"),
|
||||
avg_cadence=parsed.get("avg_cadence"),
|
||||
avg_power=parsed.get("avg_power"),
|
||||
normalized_power=parsed.get("normalized_power"),
|
||||
avg_speed_ms=parsed.get("avg_speed_ms"),
|
||||
max_speed_ms=parsed.get("max_speed_ms"),
|
||||
avg_temperature_c=parsed.get("avg_temperature_c"),
|
||||
calories=parsed.get("calories"),
|
||||
training_stress_score=parsed.get("training_stress_score"),
|
||||
polyline=parsed.get("polyline"),
|
||||
bounding_box=parsed.get("bounding_box"),
|
||||
source_file=file_path,
|
||||
source_type=parsed.get("source_type"),
|
||||
hr_zones=hr_zones,
|
||||
)
|
||||
db.add(activity)
|
||||
db.flush()
|
||||
|
||||
seen = set()
|
||||
batch = []
|
||||
for p in parsed.get("data_points", []):
|
||||
if not p.get("timestamp"):
|
||||
continue
|
||||
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
|
||||
key = (activity.id, ts)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
batch.append(ActivityDataPoint(
|
||||
activity_id=activity.id,
|
||||
timestamp=ts,
|
||||
latitude=p.get("latitude"),
|
||||
longitude=p.get("longitude"),
|
||||
altitude_m=p.get("altitude_m"),
|
||||
heart_rate=p.get("heart_rate"),
|
||||
cadence=p.get("cadence"),
|
||||
speed_ms=p.get("speed_ms"),
|
||||
power=p.get("power"),
|
||||
temperature_c=p.get("temperature_c"),
|
||||
distance_m=p.get("distance_m"),
|
||||
))
|
||||
if len(batch) >= 500:
|
||||
db.add_all(batch)
|
||||
db.flush()
|
||||
batch = []
|
||||
if batch:
|
||||
db.add_all(batch)
|
||||
db.flush()
|
||||
|
||||
for lap in parsed.get("laps", []):
|
||||
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
|
||||
db.add(ActivityLap(
|
||||
activity_id=activity.id,
|
||||
lap_number=lap["lap_number"],
|
||||
start_time=ls,
|
||||
duration_s=lap.get("duration_s"),
|
||||
distance_m=lap.get("distance_m"),
|
||||
avg_heart_rate=lap.get("avg_heart_rate"),
|
||||
avg_cadence=lap.get("avg_cadence"),
|
||||
avg_speed_ms=lap.get("avg_speed_ms"),
|
||||
avg_power=lap.get("avg_power"),
|
||||
))
|
||||
|
||||
db.commit()
|
||||
activity_id = activity.id
|
||||
|
||||
compute_personal_records.delay(activity_id, user_id, parsed)
|
||||
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
|
||||
detect_route.delay(activity_id, user_id)
|
||||
return {"activity_id": activity_id, "status": "ok"}
|
||||
|
||||
|
||||
@celery_app.task(name="parse_wellness_fit")
|
||||
def parse_wellness_fit(file_path: str, user_id: int):
|
||||
"""Parse a Garmin wellness FIT file and upsert into health_metrics."""
|
||||
from app.services.wellness_parser import parse_wellness_fit as _parse
|
||||
from app.core.database import SyncSessionLocal
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
result = _parse(file_path)
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e), "file": file_path}
|
||||
|
||||
if result.get("error"):
|
||||
return {"status": "error", "error": result["error"], "file": file_path}
|
||||
|
||||
days = result.get("days", {})
|
||||
if not days:
|
||||
return {"status": "no_data", "file": file_path}
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
for day_date, data in days.items():
|
||||
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
|
||||
db.execute(text("""
|
||||
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,
|
||||
steps, floors_climbed, active_calories, total_calories,
|
||||
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,
|
||||
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
|
||||
:steps, :floors, :active_cal, :total_cal,
|
||||
: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
|
||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
||||
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
|
||||
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
|
||||
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
|
||||
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
|
||||
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
|
||||
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 = GREATEST(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||
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_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_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,
|
||||
"resting_hr": data.get("resting_hr"),
|
||||
"avg_hr": data.get("avg_hr_day"),
|
||||
"max_hr": data.get("max_hr_day"),
|
||||
"avg_stress": data.get("avg_stress"),
|
||||
"spo2_avg": data.get("spo2_avg"),
|
||||
"hrv_avg": data.get("hrv_nightly_avg"),
|
||||
"hrv_high": data.get("hrv_5min_high"),
|
||||
"hrv_status": data.get("hrv_status"),
|
||||
"steps": data.get("steps"),
|
||||
"floors": data.get("floors_climbed"),
|
||||
"active_cal": data.get("active_calories"),
|
||||
"total_cal": data.get("total_calories"),
|
||||
"sleep_dur": data.get("sleep_duration_s"),
|
||||
"sleep_deep": data.get("sleep_deep_s"),
|
||||
"sleep_light": data.get("sleep_light_s"),
|
||||
"sleep_rem": data.get("sleep_rem_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()
|
||||
|
||||
return {"status": "ok", "days_processed": len(days), "file": file_path}
|
||||
|
||||
|
||||
@celery_app.task(name="detect_route")
|
||||
def detect_route(activity_id: int, user_id: int):
|
||||
"""Auto-detect and link activities to named routes."""
|
||||
from app.services.route_matcher import routes_are_similar
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Activity, NamedRoute
|
||||
from sqlalchemy import select
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
new_act = db.execute(
|
||||
select(Activity).where(Activity.id == activity_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not new_act or not new_act.polyline:
|
||||
return {"status": "no_polyline"}
|
||||
|
||||
if new_act.named_route_id:
|
||||
return {"status": "already_assigned"}
|
||||
|
||||
routes = db.execute(
|
||||
select(NamedRoute).where(
|
||||
NamedRoute.user_id == user_id,
|
||||
NamedRoute.sport_type == new_act.sport_type,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for route in routes:
|
||||
if route.reference_polyline and routes_are_similar(
|
||||
new_act.polyline, route.reference_polyline,
|
||||
new_act.bounding_box, route.bounding_box,
|
||||
dist1=new_act.distance_m, dist2=route.distance_m,
|
||||
):
|
||||
new_act.named_route_id = route.id
|
||||
db.commit()
|
||||
return {"status": "matched_existing", "route_id": route.id}
|
||||
|
||||
candidates = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
Activity.sport_type == new_act.sport_type,
|
||||
Activity.named_route_id == None,
|
||||
Activity.id != activity_id,
|
||||
Activity.polyline != None,
|
||||
Activity.distance_m >= (new_act.distance_m or 0) * 0.95,
|
||||
Activity.distance_m <= (new_act.distance_m or 0) * 1.05,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for candidate in candidates:
|
||||
if routes_are_similar(
|
||||
new_act.polyline, candidate.polyline,
|
||||
new_act.bounding_box, candidate.bounding_box,
|
||||
dist1=new_act.distance_m, dist2=candidate.distance_m,
|
||||
):
|
||||
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
|
||||
|
||||
route_name = f"{older.sport_type.title()} route {older.start_time.strftime('%d %b %Y')}"
|
||||
new_route = NamedRoute(
|
||||
user_id=user_id,
|
||||
name=route_name,
|
||||
sport_type=older.sport_type,
|
||||
reference_polyline=older.polyline,
|
||||
bounding_box=older.bounding_box,
|
||||
distance_m=older.distance_m,
|
||||
auto_detected=True,
|
||||
)
|
||||
db.add(new_route)
|
||||
db.flush()
|
||||
older.named_route_id = new_route.id
|
||||
newer.named_route_id = new_route.id
|
||||
db.commit()
|
||||
return {"status": "auto_created", "route_id": new_route.id}
|
||||
|
||||
return {"status": "no_match"}
|
||||
|
||||
|
||||
@celery_app.task(name="compute_personal_records")
|
||||
def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
|
||||
"""Calculate personal records for standard distances from this activity."""
|
||||
from app.services.route_matcher import compute_best_splits, STANDARD_DISTANCES
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import PersonalRecord
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone
|
||||
|
||||
data_points = parsed.get("data_points", [])
|
||||
total_dist = parsed.get("distance_m", 0) or 0
|
||||
sport = parsed.get("sport_type", "running")
|
||||
start_time_str = parsed.get("start_time")
|
||||
start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc)
|
||||
|
||||
best_splits = compute_best_splits(data_points, total_dist)
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
for label, duration_s in best_splits.items():
|
||||
dist_m = next((d for d, l in STANDARD_DISTANCES if l == label), None)
|
||||
if dist_m is None:
|
||||
continue
|
||||
|
||||
current = db.execute(
|
||||
select(PersonalRecord).where(
|
||||
PersonalRecord.user_id == user_id,
|
||||
PersonalRecord.sport_type == sport,
|
||||
PersonalRecord.distance_m == dist_m,
|
||||
PersonalRecord.is_current_record == True,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if current is None or duration_s < current.duration_s:
|
||||
if current:
|
||||
current.is_current_record = False
|
||||
db.add(PersonalRecord(
|
||||
user_id=user_id,
|
||||
activity_id=activity_id,
|
||||
sport_type=sport,
|
||||
distance_m=dist_m,
|
||||
distance_label=label,
|
||||
duration_s=duration_s,
|
||||
achieved_at=start_time,
|
||||
is_current_record=True,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
@celery_app.task(name="process_garmin_health_zip")
|
||||
def process_garmin_health_zip(zip_path: str, user_id: int):
|
||||
"""Extract wellness data from a Garmin Connect export ZIP."""
|
||||
import zipfile
|
||||
import json
|
||||
from app.core.database import SyncSessionLocal
|
||||
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 zipfile.ZipFile(zip_path) as zf:
|
||||
for name in zf.namelist():
|
||||
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
|
||||
with zf.open(name) as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
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()
|
||||
|
||||
|
||||
@celery_app.task(name="sync_garmin_connect_user")
|
||||
def sync_garmin_connect_user(user_id: int):
|
||||
"""Sync Garmin Connect data (activities + wellness) for one user."""
|
||||
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
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
cfg = db.execute(
|
||||
select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not cfg or not cfg.sync_enabled:
|
||||
return {"status": "skipped"}
|
||||
|
||||
# Snapshot config values before any intermediate commits (commits expire ORM attrs)
|
||||
email = cfg.email
|
||||
password_enc = cfg.password_enc
|
||||
token_store = cfg.token_store
|
||||
last_sync_at = cfg.last_sync_at
|
||||
sync_acts = cfg.sync_activities
|
||||
sync_well = cfg.sync_wellness
|
||||
lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
|
||||
|
||||
cfg.last_sync_status = "Connecting to Garmin..."
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
garmin, new_token = authenticate_garmin(email, password_enc, token_store)
|
||||
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()
|
||||
@@ -0,0 +1,27 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.0
|
||||
sqlalchemy[asyncio]==2.0.30
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
pydantic==2.7.1
|
||||
pydantic-settings==2.2.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.9
|
||||
httpx==0.27.0
|
||||
redis[hiredis]==5.0.4
|
||||
celery[redis]==5.4.0
|
||||
garmin-fit-sdk==21.195.0
|
||||
gpxpy==1.6.2
|
||||
numpy==1.26.4
|
||||
scipy==1.13.0
|
||||
geopy==2.4.1
|
||||
polyline==2.0.2
|
||||
Pillow==10.3.0
|
||||
aiofiles==23.2.1
|
||||
python-dateutil==2.9.0
|
||||
pytz==2024.1
|
||||
psycopg2-binary==2.9.9
|
||||
garminconnect==0.2.24
|
||||
cryptography==42.0.8
|
||||
@@ -0,0 +1,114 @@
|
||||
version: "3.9"
|
||||
|
||||
# MileVault — standalone deployment
|
||||
#
|
||||
# 1. Copy this file somewhere on your server (no other files needed)
|
||||
# 2. Run: docker compose up -d
|
||||
# 3. Visit http://localhost
|
||||
#
|
||||
# Images are pulled from your Gitea container registry automatically.
|
||||
# To update to the latest build: docker compose pull && docker compose up -d
|
||||
|
||||
# ── Replace these with your actual Gitea host and username ───────────────────
|
||||
x-registry: ®istry gitea.yourdomain.com/yourusername
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
db:
|
||||
image: timescale/timescaledb:latest-pg16
|
||||
container_name: milevault_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: milevault
|
||||
POSTGRES_USER: ${DB_USER:-milevault}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: milevault_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
backend:
|
||||
image: gitea.yourdomain.com/yourusername/milevault-backend:latest
|
||||
container_name: milevault_backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
||||
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
||||
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
||||
FILE_STORE_PATH: /data/files
|
||||
ENVIRONMENT: production
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
worker:
|
||||
image: gitea.yourdomain.com/yourusername/milevault-worker:latest
|
||||
container_name: milevault_worker
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
||||
FILE_STORE_PATH: /data/files
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: gitea.yourdomain.com/yourusername/milevault-frontend:latest
|
||||
container_name: milevault_frontend
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: milevault_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
redis_data:
|
||||
file_data:
|
||||
@@ -0,0 +1,111 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: timescale/timescaledb:latest-pg16
|
||||
container_name: milevault_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: milevault
|
||||
POSTGRES_USER: ${DB_USER:-milevault}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: milevault_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: milevault_backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
||||
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
||||
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
||||
FILE_STORE_PATH: /data/files
|
||||
ENVIRONMENT: ${ENVIRONMENT:-production}
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.worker
|
||||
container_name: milevault_worker
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||
FILE_STORE_PATH: /data/files
|
||||
volumes:
|
||||
- file_data:/data/files
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL:-/api}
|
||||
VITE_MAPBOX_TOKEN: ${VITE_MAPBOX_TOKEN:-}
|
||||
container_name: milevault_frontend
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: milevault_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
redis_data:
|
||||
file_data:
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Enable TimescaleDB extension
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- Activity data points will use TimescaleDB hypertable for efficient
|
||||
-- time-series queries on HR, cadence, power, temperature, etc.
|
||||
-- Tables are created by Alembic migrations; this just ensures extensions exist.
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
ARG VITE_API_URL=/api
|
||||
ARG VITE_MAPBOX_TOKEN=
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MileVault</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "milevault-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^2.12.7",
|
||||
"date-fns": "^3.6.0",
|
||||
"clsx": "^2.1.1",
|
||||
"zustand": "^4.5.2",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"axios": "^1.7.2",
|
||||
"react-dropzone": "^14.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.2.13",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { useAuthStore } from './hooks/useAuth'
|
||||
import Layout from './components/ui/Layout'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import ActivitiesPage from './pages/ActivitiesPage'
|
||||
import ActivityDetailPage from './pages/ActivityDetailPage'
|
||||
import HealthPage from './pages/HealthPage'
|
||||
import RoutesPage from './pages/RoutesPage'
|
||||
import SegmentsPage from './pages/SegmentsPage'
|
||||
import RecordsPage from './pages/RecordsPage'
|
||||
import UploadPage from './pages/UploadPage'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
import UsersPage from './pages/UsersPage'
|
||||
|
||||
function RequireAuth({ children }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
return children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { token, fetchUser } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (token) fetchUser()
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="activities" element={<ActivitiesPage />} />
|
||||
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
||||
<Route path="health" element={<HealthPage />} />
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="segments" element={<SegmentsPage />} />
|
||||
<Route path="records" element={<RecordsPage />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { sportColor } from '../../utils/format'
|
||||
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
})
|
||||
|
||||
const TILE_LAYERS = {
|
||||
dark: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
},
|
||||
street: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
},
|
||||
satellite: {
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© <a href="https://www.esri.com/">Esri</a>',
|
||||
},
|
||||
}
|
||||
|
||||
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 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' }) {
|
||||
const mapRef = useRef(null)
|
||||
const mapInstanceRef = useRef(null)
|
||||
const markerRef = useRef(null)
|
||||
const trackRef = 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(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return
|
||||
|
||||
mapInstanceRef.current = L.map(mapRef.current, {
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
|
||||
if (tileLayerRef.current) tileLayerRef.current.remove()
|
||||
tileLayerRef.current = L.tileLayer(tile.url, {
|
||||
attribution: tile.attribution,
|
||||
maxZoom: 19,
|
||||
}).addTo(mapInstanceRef.current)
|
||||
drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef)
|
||||
}, [mapType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
drawRoute(mapInstanceRef.current, polyline, sportType, trackRef)
|
||||
}, [polyline, sportType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
|
||||
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
|
||||
if (!point?.latitude || !point?.longitude) return
|
||||
if (markerRef.current) {
|
||||
markerRef.current.setLatLng([point.latitude, point.longitude])
|
||||
} else {
|
||||
const icon = L.divIcon({
|
||||
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: '',
|
||||
})
|
||||
markerRef.current = L.marker([point.latitude, point.longitude], { icon })
|
||||
.addTo(mapInstanceRef.current)
|
||||
}
|
||||
}, [hoveredDistance, dataPoints])
|
||||
|
||||
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user