Files
MileVault/CLAUDE.md
T
owain 4a4cbdcc92
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 8s
Fix pace sentinel, route map thumbnails, tiled segments, health/dashboard layout
- Pace: FIT 0xFFFF sentinel (65.535 m/s) was stored as avg_speed_ms on every
  activity and lap; add _sanitize_speed() to parser falling back to dist/dur,
  plus a startup SQL migration that fixed 120 activities and 688 laps in-place
- Records: remove swimming from Distance PRs; Route Records rows are clickable
  (navigate to activity), View button removed, small SVG route map per row;
  Segment Records uses same tiled route-card layout as Segments page
- Segments: replace route dropdown with responsive tile grid showing SVG map
  thumbnails; selecting a tile reveals the segment management panel below
- RouteMiniMap: new pure-SVG component (no Leaflet) for route thumbnails,
  decodes polyline and normalises coords into a fixed viewBox
- Health: rename "Avg Heart Rate (day)" → "Heart Rate"; weight chart now
  filters to non-null rows and enables connectNulls + dots for sparse data
- Dashboard: 4-col layout at lg breakpoint so Body Battery sits between weekly
  chart and Health Today; Body Battery card gains a 24-hr sparkline from the
  values[] already present in the health summary response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:36:54 +01:00

115 lines
5.9 KiB
Markdown

# 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.
## 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`.
```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) |
| `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`
- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `RouteSegment`, `PersonalRecord`, `HealthMetric`, `WeightLog`
- `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
- `workers/tasks.py` — Celery tasks: `process_activity_file`, `parse_wellness_fit`, `detect_route`, `compute_personal_records`, `process_garmin_health_zip`
### 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. 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
- `pages/` — one file per route
- `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`
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` |
| `ADMIN_PASSWORD` | Seeds the admin user on first start |
| `REDIS_URL` | Celery broker |
| `BASE_URL` | Used for PocketID OAuth callback redirect URI |
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer |
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
## Debugging and troubleshooting
- The latest 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, rectify the issues in ~/milevault where the development is happening
- Do NOT patch the running files under any circumstances, fix the development files.