Files
MileVault/CLAUDE.md
T
owain ec87f68729
Build and push images / validate (push) Successful in 9s
Build and push images / build-backend (push) Successful in 1m57s
Build and push images / build-worker (push) Successful in 50s
Build and push images / build-frontend (push) Successful in 24s
Add trend-range gating, vehicle filter, sync cancel, moving time, and UI fixes
- Grey out trend ranges beyond available health history
- Reject implausibly fast (vehicle) activities on upload with feedback
- Add cancel button + cooperative cancellation for Garmin sync
- Show daily steps prominently on the dashboard
- Clear errors for malformed/empty upload ZIPs
- Snap-target dot when drawing a segment on the map
- Time-axis fallback for stationary/HIIT HR timelines; hide map when no GPS
- Parse and display moving time (timer) vs elapsed; backfill task
- Restyle SegmentsPanel like RouteLeaderboard; Laps/Routes/Segments on one row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:41:56 +01:00

10 KiB

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.

# 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.

There are no automated tests. Verification is done by running the app and observing behaviour.

Debugging running containers

The production stack runs in ~/milevault_docker with fixed container names. Use these to investigate issues — never patch the running files:

# Tail logs from a specific container
docker logs -f milevault_backend
docker logs -f milevault_worker
docker logs -f milevault_db

# Run a one-off query or command inside a container
docker exec milevault_backend python -c "from app.core.config import settings; print(settings.base_url)"
docker exec -it milevault_db psql -U milevault -d milevault

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.

./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.

CI validation: The build workflow runs a validate job before building images. It will fail if @polyline-codec appears in frontend/package.json or if npm ci is used in frontend/Dockerfile (no lockfile exists — always use npm install). Fix these before pushing.

VITE_MAPBOX_TOKEN is baked empty by the CI build (build-args: VITE_MAPBOX_TOKEN=), so satellite tiles are disabled in all pre-built images. To enable them, rebuild locally with the token set in .env.

# 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)
beat Celery Beat scheduler — runs sync_all_garmin_connect every 30 minutes
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, garmin_sync, users, segments
  • models/user.py — all SQLAlchemy models: User, Activity, ActivityDataPoint, ActivityLap, NamedRoute, Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig (the old RouteSegment model was removed in the segments rewrite; a new GPS-geometry Segment/SegmentEffort pair replaces it)
  • 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
  • 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
  • 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

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. Post-initial schema changes (new columns, constraint changes) are applied as ALTER TABLE ... ADD COLUMN IF NOT EXISTS / DROP CONSTRAINT IF EXISTS statements in init_db() in main.py — this is the only place schema migrations happen. 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
  • TanStack Query (@tanstack/react-query) handles all server-state fetching and caching; Zustand is used only for auth state
  • utils/format.js — shared formatting helpers: formatDuration, formatPace, formatDistance, formatCadence, hrZoneColor, sportIcon, sportColor, etc.
  • pages/ — one file per route: Dashboard, Activities, ActivityDetail, Routes, Records, Health, 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/ui/Layout (nav shell), StatCard, RouteMiniMap (small Leaflet map used in route/segment cards)

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; also used as Fernet key for Garmin credentials
ADMIN_USERNAME Admin account username (default: admin)
ADMIN_PASSWORD Seeds the admin user on first start
REDIS_URL Celery broker
DB_USER / DB_PASSWORD Postgres credentials (compose-level; default: milevault)
REDIS_PASSWORD Redis auth (compose-level; default: milevault)
HTTP_PORT Host port for nginx (default: 80)
FILE_STORE_PATH Where uploaded FIT files are stored (default: /data/files)
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)
POCKETID_ISSUER / POCKETID_CLIENT_ID / POCKETID_CLIENT_SECRET Optional OIDC
POCKETID_ALLOWED_GROUP Optional — restrict passkey login to a specific PocketID group

milevault_export/

milevault_export/ is a sanitised snapshot of the project used for public distribution (stripped of dev-only configs). It mirrors the main project structure. When making changes that affect deployment files (docker-compose.yml, nginx.conf, scripts/manage.sh, docker/init.sql, etc.), keep this directory in sync manually.

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.