From acf553ff5cc32c3ab4bc35d58a788b22cdbf3703 Mon Sep 17 00:00:00 2001 From: owain Date: Wed, 1 Jul 2026 21:01:42 +0100 Subject: [PATCH] =?UTF-8?q?docs:=20correct=20CLAUDE.md=20drift=20=E2=80=94?= =?UTF-8?q?=20beat=20also=20schedules=20sync=5Fall=5Fstrava;=20three=20ngi?= =?UTF-8?q?nx=20configs=20(dev=20nginx/,=20deploy=20root,=20in-image=20SPA?= =?UTF-8?q?)=20with=20prod=20stack=20using=20Traefik=20labels;=20persist?= =?UTF-8?q?=5Factivity=20lives=20in=20workers/tasks.py;=20add=20missing=20?= =?UTF-8?q?Celery=20tasks=20and=20ui=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fd19605..edd40ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ docker exec -it milevault_db psql -U milevault -d milevault `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`. +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 ""` 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 | | `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 | +| `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 | | `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/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/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 -- `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` +- `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`, `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 @@ -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` - `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/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. ### 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. -**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):** - 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 | | `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`) | +| `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) | | `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC | | `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |