Compare commits
2
Commits
4d5938cf33
...
92cea4dc99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92cea4dc99 | ||
|
|
acf553ff5c |
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user