diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fa25c15 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# 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 | diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py index bcd2fdd..b061606 100644 --- a/backend/app/api/profile.py +++ b/backend/app/api/profile.py @@ -55,6 +55,7 @@ async def update_profile( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + old_max_hr = current_user.max_heart_rate if body.max_heart_rate is not None: if not (100 <= body.max_heart_rate <= 250): raise HTTPException(400, "Max HR must be 100–250") @@ -74,6 +75,11 @@ async def update_profile( await db.commit() await db.refresh(current_user) + + if body.max_heart_rate is not None and body.max_heart_rate != old_max_hr: + from app.workers.tasks import recalculate_hr_zones_for_user + recalculate_hr_zones_for_user.delay(current_user.id, body.max_heart_rate) + return {**{c.name: getattr(current_user, c.name) for c in User.__table__.columns}, "estimated_max_hr": _estimated_max_hr(current_user)} diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 2cfbd37..40d6ac4 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -82,7 +82,7 @@ async def upload_garmin_export( return { "status": "queued", "activity_tasks": len(task_ids), - "health_task": health_task.id, + "task_id": health_task.id, } @@ -116,6 +116,7 @@ async def upload_strava_export( return { "status": "queued", "activity_tasks": len(task_ids), + "task_id": task_ids[-1] if task_ids else None, } diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 10e1fb9..1a66b2a 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -439,4 +439,29 @@ def process_garmin_health_zip(zip_path: str, user_id: int): "spo2": data.get("avgSpo2"), }) - db.commit() \ No newline at end of file + db.commit() + + +@celery_app.task(name="recalculate_hr_zones_for_user") +def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float): + """Recalculate hr_zones for all of a user's activities using a new max HR.""" + from app.services.fit_parser import calculate_hr_zones + from app.core.database import SyncSessionLocal + from app.models.user import Activity, ActivityDataPoint + from sqlalchemy import select + + with SyncSessionLocal() as db: + activities = db.execute( + select(Activity).where(Activity.user_id == user_id) + ).scalars().all() + + for activity in activities: + data_points = db.execute( + select(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity.id) + ).scalars().all() + points_dicts = [{"heart_rate": dp.heart_rate} for dp in data_points] + new_zones = calculate_hr_zones(points_dicts, new_max_hr) + if new_zones: + activity.hr_zones = new_zones + + db.commit() diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index 0b168b1..801b645 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' import { useAuthStore } from '../hooks/useAuth' @@ -59,6 +59,8 @@ export default function ProfilePage() { // HR / measurements form const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' }) const [hrSaved, setHrSaved] = useState(false) + const [hrZoneRecalc, setHrZoneRecalc] = useState(false) + const maxHrChangedRef = useRef(false) useEffect(() => { if (profile) setHrForm({ max_heart_rate: profile.max_heart_rate || '', @@ -70,7 +72,16 @@ export default function ProfilePage() { const updateProfile = useMutation({ mutationFn: data => api.patch('/profile/', data).then(r => r.data), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['profile'] }) + setHrSaved(true) + setTimeout(() => setHrSaved(false), 3000) + if (maxHrChangedRef.current) { + setHrZoneRecalc(true) + setTimeout(() => setHrZoneRecalc(false), 6000) + maxHrChangedRef.current = false + } + }, }) // Weight log @@ -149,12 +160,19 @@ export default function ProfilePage() { updateProfile.mutate(Object.fromEntries( - Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)]) - ))} + onClick={() => { + const data = Object.fromEntries( + Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)]) + ) + maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate + updateProfile.mutate(data) + }} loading={updateProfile.isPending} saved={hrSaved} /> + {hrZoneRecalc && ( +

HR zones are being recalculated for your existing activities.

+ )} {/* Weight log */} diff --git a/frontend/src/pages/UploadPage.jsx b/frontend/src/pages/UploadPage.jsx index 62dc1c5..96a5924 100644 --- a/frontend/src/pages/UploadPage.jsx +++ b/frontend/src/pages/UploadPage.jsx @@ -1,10 +1,38 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { useDropzone } from 'react-dropzone' -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' function UploadZone({ title, description, accept, endpoint, icon }) { const [tasks, setTasks] = useState([]) + const queryClient = useQueryClient() + const intervalsRef = useRef({}) + + const pollTask = useCallback((taskId) => { + if (intervalsRef.current[taskId]) return + const intervalId = setInterval(async () => { + try { + const { data } = await api.get(`/upload/task/${taskId}`) + if (data.status === 'SUCCESS' || data.status === 'FAILURE') { + clearInterval(intervalsRef.current[taskId]) + delete intervalsRef.current[taskId] + setTasks(ts => ts.map(t => + t.task_id === taskId ? { ...t, status: data.status === 'SUCCESS' ? 'done' : 'failed' } : t + )) + if (data.status === 'SUCCESS') { + queryClient.invalidateQueries({ queryKey: ['activities'] }) + queryClient.invalidateQueries({ queryKey: ['health-summary'] }) + queryClient.invalidateQueries({ queryKey: ['health-metrics'] }) + } + } + } catch { /* ignore transient poll errors */ } + }, 2000) + intervalsRef.current[taskId] = intervalId + }, [queryClient]) + + useEffect(() => { + return () => { Object.values(intervalsRef.current).forEach(clearInterval) } + }, []) const upload = useMutation({ mutationFn: async (file) => { @@ -16,7 +44,11 @@ function UploadZone({ title, description, accept, endpoint, icon }) { return { file: file.name, ...data } }, onSuccess: (data) => { - setTasks(t => [...t, { ...data, status: 'queued' }]) + const task = { ...data, status: data.task_id ? 'processing' : 'queued' } + setTasks(t => [...t, task]) + if (data.task_id) { + pollTask(data.task_id) + } }, }) @@ -30,6 +62,13 @@ function UploadZone({ title, description, accept, endpoint, icon }) { multiple: true, }) + function StatusBadge({ status }) { + if (status === 'processing') return ⏳ Processing + if (status === 'done') return ✓ Done + if (status === 'failed') return ✗ Failed + return ✓ Queued + } + return (
@@ -73,7 +112,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) { {task.activity_tasks !== undefined && ( {task.activity_tasks} activities queued )} - ✓ Queued +
))}