Fix upload auto-refresh, health data refresh, and HR zone recalculation
- UploadPage now polls task status every 2s and invalidates activity, health-summary, and health-metrics queries on completion so new activities and health data appear without a hard refresh - Garmin and Strava export endpoints now return a task_id for polling - Updating max HR in Profile triggers a background Celery task to recalculate hr_zones for all existing activities; profile page shows a confirmation note when this is queued - Add CLAUDE.md with repo architecture and dev commands Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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=<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 |
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -439,4 +439,29 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
|
||||
"spo2": data.get("avgSpo2"),
|
||||
})
|
||||
|
||||
db.commit()
|
||||
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()
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<SaveButton
|
||||
onClick={() => 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 && (
|
||||
<p className="text-xs text-blue-400 mt-1">HR zones are being recalculated for your existing activities.</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Weight log */}
|
||||
|
||||
@@ -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 <span className="ml-2 text-blue-400 animate-pulse">⏳ Processing</span>
|
||||
if (status === 'done') return <span className="ml-2 text-green-400">✓ Done</span>
|
||||
if (status === 'failed') return <span className="ml-2 text-red-400">✗ Failed</span>
|
||||
return <span className="ml-2 text-green-400">✓ Queued</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
@@ -73,7 +112,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
{task.activity_tasks !== undefined && (
|
||||
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
|
||||
)}
|
||||
<span className="ml-2 text-green-400">✓ Queued</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user