Compare commits

...
2 Commits
3 changed files with 20 additions and 13 deletions
+10 -9
View File
@@ -54,7 +54,7 @@ docker exec -it milevault_db psql -U milevault -d milevault
`docker-compose.yml` — build from source (dev/CI). `docker-compose.yml` — build from source (dev/CI).
`docker-compose.deploy.yml` — pull pre-built images from the Gitea registry (production). `docker-compose.deploy.yml` — pull pre-built images from the Gitea registry (production).
The Gitea Actions workflow (`.gitea/workflows/build.yml`) auto-builds and pushes images on push to `main`. Deployment machines only need `docker-compose.deploy.yml` and `nginx.conf`. The Gitea Actions workflow (`.gitea/workflows/build.yml`) auto-builds and pushes images on push to `main`. Deployment machines only need `docker-compose.deploy.yml` and `nginx.conf` (`install.sh` is a curl-able installer that automates that two-file setup).
`./deploy.sh "<commit message>"` is the normal dev loop here: it commits everything, pushes to `main` (triggering the image build), and stops the running stack in `../milevault_docker`. After the build finishes, run `docker compose pull && docker compose up -d` there. This matches the repo rule: fix files in `~/milevault`, push to git — never patch the running containers in `~/milevault_docker`. `./deploy.sh "<commit message>"` is the normal dev loop here: it commits everything, pushes to `main` (triggering the image build), and stops the running stack in `../milevault_docker`. After the build finishes, run `docker compose pull && docker compose up -d` there. This matches the repo rule: fix files in `~/milevault`, push to git — never patch the running containers in `~/milevault_docker`.
@@ -82,7 +82,7 @@ docker compose -f docker-compose.deploy.yml up -d
| `redis` | Celery broker + result backend | | `redis` | Celery broker + result backend |
| `backend` | FastAPI (async) — uvicorn, single worker | | `backend` | FastAPI (async) — uvicorn, single worker |
| `worker` | Celery worker — synchronous SQLAlchemy (asyncio incompatible with prefork) | | `worker` | Celery worker — synchronous SQLAlchemy (asyncio incompatible with prefork) |
| `beat` | Celery Beat scheduler — runs `sync_all_garmin_connect` every 30 minutes | | `beat` | Celery Beat scheduler — runs `sync_all_garmin_connect` and `sync_all_strava` on the same interval (default 30 min, `GARMIN_SYNC_INTERVAL_MINUTES`) |
| `frontend` | React SPA built by Vite at container build time | | `frontend` | React SPA built by Vite at container build time |
| `nginx` | Reverse proxy, serves the SPA | | `nginx` | Reverse proxy, serves the SPA |
@@ -96,8 +96,8 @@ docker compose -f docker-compose.deploy.yml up -d
- `services/wellness_parser.py` — parses Garmin wellness FIT files (metrics, sleep, HRV, SPO2, etc.) - `services/wellness_parser.py` — parses Garmin wellness FIT files (metrics, sleep, HRV, SPO2, etc.)
- `services/route_matcher.py` — bounding-box pre-filter + DTW (Dynamic Time Warping) for GPS track similarity - `services/route_matcher.py` — bounding-box pre-filter + DTW (Dynamic Time Warping) for GPS track similarity
- `services/garmin_connect_sync.py` — Garmin Connect API integration; `authenticate_garmin()` tries stored OAuth token first, falls back to email/password; Garmin credentials stored Fernet-encrypted using `SECRET_KEY` as the key - `services/garmin_connect_sync.py` — Garmin Connect API integration; `authenticate_garmin()` tries stored OAuth token first, falls back to email/password; Garmin credentials stored Fernet-encrypted using `SECRET_KEY` as the key
- `services/strava_sync.py` — Strava API OAuth live sync (pulls activities via streams) and bulk-export import; a shared `persist_activity` path is used by both. On dedup, existing Garmin data is preferred over Strava for the same activity - `services/strava_sync.py` — Strava API OAuth live sync (pulls activities via streams) and bulk-export import. On dedup, existing Garmin data is preferred over Strava for the same activity
- `workers/tasks.py` — Celery tasks: `process_activity_file`, `parse_wellness_fit`, `detect_route`, `compute_personal_records`, `match_segment`, `match_activity_segments`, `process_garmin_health_zip`, `sync_garmin_connect_user`, `sync_all_garmin_connect` (beat-scheduled), `recalculate_hr_zones_for_user`, `backfill_moving_time`, `backfill_indoor_distances`, `recompute_personal_records_all` - `workers/tasks.py` — Celery tasks: `process_activity_file`, `parse_wellness_fit`, `analyze_strava_export`, `detect_route`, `compute_personal_records`, `match_segment`, `match_activity_segments`, `process_garmin_health_zip`, `sync_garmin_connect_user`, `sync_all_garmin_connect` (beat-scheduled), `sync_strava_user`, `sync_all_strava` (beat-scheduled), `recalculate_hr_zones_for_user`, `backfill_moving_time`, `backfill_indoor_distances`, `recompute_personal_records_all`. Also holds `persist_activity`, the single shared write path used by every ingest route (file upload, Garmin sync, Strava sync/export)
### Key design decisions ### Key design decisions
@@ -128,18 +128,19 @@ docker compose -f docker-compose.deploy.yml up -d
- `pages/` — one `*Page.jsx` file per route: `Dashboard` (drag-to-edit widget grid), `Activities` (type/year/date-range/distance filters + week totals), `ActivityDetail`, `Routes`, `Records`, `Health`, `Summary` (all-time and per-year/per-sport totals + distance-per-year chart), `Upload`, `Profile`, `Users`, `Login` - `pages/` — one `*Page.jsx` file per route: `Dashboard` (drag-to-edit widget grid), `Activities` (type/year/date-range/distance filters + week totals), `ActivityDetail`, `Routes`, `Records`, `Health`, `Summary` (all-time and per-year/per-sport totals + distance-per-year chart), `Upload`, `Profile`, `Users`, `Login`
- `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route) - `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route)
- `components/health/``SleepHypnogram` (renders the `sleep_stages` hypnogram), `BodyBatteryChart` (Body Battery trend chart) - `components/health/``SleepHypnogram` (renders the `sleep_stages` hypnogram), `BodyBatteryChart` (Body Battery trend chart)
- `components/ui/``Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards), `UnitToggle` (km/mi switch), `HrvBadge` - `components/ui/``Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards), `RouteTileMap` (route-card map tile), `SportIcon`, `UnitToggle` (km/mi switch), `HrvBadge`
The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time. The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time.
### Request routing & browser caching ### Request routing & browser caching
There are **two** nginx configs and they serve different roles — don't confuse them: There are **three** nginx configs and they serve different roles — don't confuse them:
- `nginx.conf` (repo root) is the **dev/CI** reverse proxy (`docker-compose.yml`): one nginx that proxies `/api/``backend:8000` and `/``frontend:80`. - `nginx/nginx.conf` is the **dev** reverse proxy mounted by `docker-compose.yml`: one nginx that proxies `/api/``backend:8000` and `/``frontend:80`.
- `nginx.conf` (repo root) is the near-identical reverse proxy mounted by `docker-compose.deploy.yml`, for generic two-file deployments (see README). Keep the two in sync when touching proxy behaviour.
- `frontend/nginx-spa.conf` runs *inside the `frontend` image* and only serves the built SPA (the `milevault_frontend` container). It has no `/api` proxy. - `frontend/nginx-spa.conf` runs *inside the `frontend` image* and only serves the built SPA (the `milevault_frontend` container). It has no `/api` proxy.
**In production (`docker-compose.deploy.yml` / `~/milevault_docker`) there is no nginx reverse-proxy service — Traefik does the routing via container labels:** `Host(...) && PathPrefix(/api)` → backend, `Host(...)` → frontend SPA. The empty `~/milevault_docker/nginx.conf` is an unused leftover. So if a request reaches the SPA nginx with an `/api` path it falls through to `index.html` (returns HTML, not JSON) — a sign Traefik routing, not nginx, is the thing to debug. **The actual production stack here does not use either reverse proxy.** `~/milevault_docker/docker-compose.yml` is a hand-customised compose (not the repo's deploy file): it drops the nginx service and routes via Traefik container labels `Host(...) && PathPrefix(/api)` → backend, `Host(...)` → frontend SPA. The empty `~/milevault_docker/nginx.conf` (a directory Docker auto-created for a since-removed mount) is an unused leftover. So if a request reaches the SPA nginx with an `/api` path it falls through to `index.html` (returns HTML, not JSON) — a sign Traefik routing, not nginx, is the thing to debug.
**Caching policy (set deliberately; a wrong change here strands users on stale builds):** **Caching policy (set deliberately; a wrong change here strands users on stale builds):**
- Hashed assets (`*.js`/`*.css`) → `Cache-Control: public, immutable` (1y) in `nginx-spa.conf`. - Hashed assets (`*.js`/`*.css`) → `Cache-Control: public, immutable` (1y) in `nginx-spa.conf`.
@@ -174,7 +175,7 @@ Required in `.env` (or passed to Docker Compose):
| `BASE_URL` | Used for PocketID OAuth callback redirect URI | | `BASE_URL` | Used for PocketID OAuth callback redirect URI |
| `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) | | `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) |
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer (baked at build time) | | `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer (baked at build time) |
| `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect (default: `30`) | | `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect *and* Strava (shared cadence; default: `30`) |
| `THUNDERFOREST_DEFAULT_KEY` | Default Thunderforest tile key served to clients without their own (public client-side key; has a built-in default) | | `THUNDERFOREST_DEFAULT_KEY` | Default Thunderforest tile key served to clients without their own (public client-side key; has a built-in default) |
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC | | `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group | | `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |
+6 -2
View File
@@ -423,17 +423,21 @@ async def get_route_leaderboard(
if not act.named_route_id: if not act.named_route_id:
return None return None
# Rank by moving time (pauses excluded), falling back to elapsed for
# activities without it — consistent with avg pace being moving-time based.
effort_s = func.coalesce(Activity.moving_time_s, Activity.duration_s)
rows = (await db.execute( rows = (await db.execute(
select( select(
Activity.id, Activity.name, Activity.start_time, Activity.id, Activity.name, Activity.start_time,
Activity.duration_s, Activity.distance_m, Activity.avg_heart_rate, effort_s.label("duration_s"),
Activity.distance_m, Activity.avg_heart_rate,
) )
.where( .where(
Activity.named_route_id == act.named_route_id, Activity.named_route_id == act.named_route_id,
Activity.user_id == current_user.id, Activity.user_id == current_user.id,
Activity.duration_s.isnot(None), Activity.duration_s.isnot(None),
) )
.order_by(Activity.duration_s) .order_by(effort_s)
)).all() )).all()
if not rows: if not rows:
return None return None
+4 -2
View File
@@ -195,11 +195,13 @@ async def route_activities(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
# Rank by moving time (pauses excluded), falling back to elapsed for
# activities without it — consistent with avg pace being moving-time based.
result = await db.execute( result = await db.execute(
select(Activity).where( select(Activity).where(
Activity.named_route_id == route_id, Activity.named_route_id == route_id,
Activity.user_id == current_user.id, Activity.user_id == current_user.id,
).order_by(Activity.duration_s) ).order_by(func.coalesce(Activity.moving_time_s, Activity.duration_s))
) )
activities = result.scalars().all() activities = result.scalars().all()
return [ return [
@@ -207,7 +209,7 @@ async def route_activities(
"id": a.id, "id": a.id,
"name": a.name, "name": a.name,
"start_time": a.start_time, "start_time": a.start_time,
"duration_s": a.duration_s, "duration_s": a.moving_time_s or a.duration_s,
"distance_m": a.distance_m, "distance_m": a.distance_m,
"avg_heart_rate": a.avg_heart_rate, "avg_heart_rate": a.avg_heart_rate,
"avg_speed_ms": a.avg_speed_ms, "avg_speed_ms": a.avg_speed_ms,