# 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=` 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 |