# 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 | ## Rules - The current 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, use these for finding the problem, rectify the issues in ~/milevault project without running the updated versions, push to git instead. - Do NOT patch the running files under any circumstances, fix the development files.