diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py
index 089a8ee..dc073ee 100644
--- a/backend/app/api/activities.py
+++ b/backend/app/api/activities.py
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc, delete
+from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
@@ -28,6 +29,7 @@ class ActivitySummary(BaseModel):
bounding_box: Optional[dict]
hr_zones: Optional[dict]
named_route_id: Optional[int]
+ named_route_name: Optional[str] = None
class Config:
from_attributes = True
@@ -110,7 +112,7 @@ async def list_activities(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- q = select(Activity).where(Activity.user_id == current_user.id)
+ q = select(Activity).options(selectinload(Activity.named_route)).where(Activity.user_id == current_user.id)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
@@ -133,7 +135,7 @@ async def get_activity(
current_user: User = Depends(get_current_user),
):
result = await db.execute(
- select(Activity).where(
+ select(Activity).options(selectinload(Activity.named_route)).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
diff --git a/backend/app/api/health.py b/backend/app/api/health.py
index 290f096..9898552 100644
--- a/backend/app/api/health.py
+++ b/backend/app/api/health.py
@@ -22,6 +22,8 @@ class HealthMetricOut(BaseModel):
hrv_status: Optional[str]
hrv_5min_high: Optional[float]
hrv_5min_low: Optional[float]
+ hrv_baseline_low: Optional[float]
+ hrv_baseline_upper: Optional[float]
sleep_duration_s: Optional[float]
sleep_deep_s: Optional[float]
sleep_light_s: Optional[float]
diff --git a/backend/app/main.py b/backend/app/main.py
index 5133fcc..3182edc 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -68,6 +68,8 @@ async def init_db():
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON",
+ "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS hrv_baseline_low FLOAT",
+ "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS hrv_baseline_upper FLOAT",
]:
await conn.execute(text(stmt))
except Exception as e:
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 16fec31..215b358 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -125,6 +125,16 @@ class Activity(Base):
named_route = relationship("NamedRoute", back_populates="activities")
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
+ @property
+ def named_route_name(self):
+ """Name of the associated NamedRoute, or None. Reads the relationship only
+ if it was eager-loaded (selectinload) so it never triggers a lazy load in
+ the async request context — returns None when unloaded."""
+ from sqlalchemy import inspect as sa_inspect
+ if "named_route" in sa_inspect(self).unloaded:
+ return None
+ return self.named_route.name if self.named_route else None
+
class ActivityDataPoint(Base):
__tablename__ = "activity_data_points"
@@ -254,6 +264,8 @@ class HealthMetric(Base):
hrv_nightly_avg = Column(Float, nullable=True)
hrv_5min_high = Column(Float, nullable=True)
hrv_5min_low = Column(Float, nullable=True)
+ hrv_baseline_low = Column(Float, nullable=True) # Garmin balanced range lower bound (balancedLow)
+ hrv_baseline_upper = Column(Float, nullable=True) # Garmin balanced range upper bound (balancedUpper)
sleep_duration_s = Column(Float, nullable=True)
sleep_deep_s = Column(Float, nullable=True)
sleep_light_s = Column(Float, nullable=True)
diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py
index 6e4d537..6020fc9 100644
--- a/backend/app/services/garmin_connect_sync.py
+++ b/backend/app/services/garmin_connect_sync.py
@@ -602,6 +602,10 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
status = summary.get("status")
if status:
row["hrv_status"] = str(status).lower()
+ # Garmin's per-day balanced baseline range (the grey band in the app).
+ baseline = summary.get("baseline") or {}
+ _set(row, "hrv_baseline_low", baseline.get("balancedLow"))
+ _set(row, "hrv_baseline_upper", baseline.get("balancedUpper"))
return row
diff --git a/docker-compose.yml b/docker-compose.yml
index 069a296..97ed2fa 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,7 +10,7 @@ services:
POSTGRES_USER: ${DB_USER:-milevault}
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
volumes:
- - db_data:/var/lib/postgresql/data
+ - ./db_data:/var/lib/postgresql/data
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
@@ -25,7 +25,7 @@ services:
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
volumes:
- - redis_data:/data
+ - ./redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
interval: 10s
@@ -51,7 +51,7 @@ services:
FILE_STORE_PATH: /data/files
ENVIRONMENT: ${ENVIRONMENT:-production}
volumes:
- - file_data:/data/files
+ - ./file_data:/data/files
depends_on:
db:
condition: service_healthy
@@ -76,7 +76,7 @@ services:
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
FILE_STORE_PATH: /data/files
volumes:
- - file_data:/data/files
+ - ./file_data:/data/files
depends_on:
db:
condition: service_healthy
@@ -96,7 +96,7 @@ services:
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
FILE_STORE_PATH: /data/files
volumes:
- - file_data:/data/files
+ - ./file_data:/data/files
depends_on:
db:
condition: service_healthy
@@ -124,8 +124,3 @@ services:
depends_on:
- backend
- frontend
-
-volumes:
- db_data:
- redis_data:
- file_data:
diff --git a/frontend/src/components/ui/HrvBadge.jsx b/frontend/src/components/ui/HrvBadge.jsx
new file mode 100644
index 0000000..75b67cf
--- /dev/null
+++ b/frontend/src/components/ui/HrvBadge.jsx
@@ -0,0 +1,15 @@
+// Coloured pill for a Garmin HRV status (balanced / unbalanced / low / poor).
+// Shared by the Health page and the Dashboard HRV widget so the palette stays
+// consistent across the app.
+const HRV_PALETTE = {
+ balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
+ unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30',
+ low: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
+ poor: 'text-red-400 bg-red-400/10 border-red-400/30',
+}
+
+export default function HrvBadge({ status }) {
+ if (!status) return null
+ const cls = HRV_PALETTE[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30'
+ return {status}
+}
diff --git a/frontend/src/hooks/useSync.js b/frontend/src/hooks/useSync.js
index ccd5c1f..19bfca8 100644
--- a/frontend/src/hooks/useSync.js
+++ b/frontend/src/hooks/useSync.js
@@ -39,17 +39,42 @@ export const useSyncStore = create((set, get) => ({
connected: false,
lastSyncAt: null,
email: '',
+ // Set when the user manually triggers a sync; cleared once the worker takes
+ // over or finishes (see poll). prevSyncAt snapshots last_sync_at at trigger
+ // time so we can detect completion without relying on clock-synced times.
+ triggeredAt: null,
+ prevSyncAt: null,
poll: async () => {
try {
const { data } = await api.get('/garmin-sync/config')
const status = data?.last_sync_status ?? ''
- const inProgress = !!status && !isTerminal(status)
+ const lastSyncAt = data?.last_sync_at ?? null
+ let inProgress = !!status && !isTerminal(status)
+
+ // Grace window after a manual trigger. The Celery worker may not have
+ // updated last_sync_status yet, so the config can still report the
+ // PREVIOUS (terminal) status. Without this, the first poll fired right
+ // after triggering would clear inProgress and the button would look dead
+ // until clicked a second time. Keep the sync "in progress" until the
+ // worker either starts (non-terminal status) or finishes (last_sync_at
+ // changed from its pre-trigger value), with a hard cap as a safety net.
+ const { triggeredAt, prevSyncAt } = get()
+ if (triggeredAt) {
+ const finished = lastSyncAt && lastSyncAt !== prevSyncAt
+ if (inProgress || finished) {
+ set({ triggeredAt: null })
+ } else if (Date.now() - triggeredAt < 90000) {
+ inProgress = true
+ } else {
+ set({ triggeredAt: null })
+ }
+ }
+
set({
status, inProgress,
connected: !!data?.connected,
- lastSyncAt: data?.last_sync_at ?? null,
- email: data?.email ?? '',
+ lastSyncAt, email: data?.email ?? '',
})
return inProgress
} catch {
@@ -74,11 +99,11 @@ export const useSyncStore = create((set, get) => ({
},
trigger: async () => {
- set({ inProgress: true, status: 'Starting sync…' })
+ set({ inProgress: true, status: 'Starting sync…', triggeredAt: Date.now(), prevSyncAt: get().lastSyncAt })
try {
await api.post('/garmin-sync/trigger')
} catch {
- set({ inProgress: false })
+ set({ inProgress: false, triggeredAt: null })
return
}
get().stopPolling()
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index f5ea046..faa585b 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -11,6 +11,7 @@ import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } f
import api from '../utils/api'
import { useIsMobile } from '../hooks/useMediaQuery'
import StatCard from '../components/ui/StatCard'
+import HrvBadge from '../components/ui/HrvBadge'
import ActivityMap from '../components/activity/ActivityMap'
import {
formatDuration, formatDistance, formatHeartRate, formatElevation,
@@ -23,20 +24,13 @@ const Grid = WidthProvider(GridLayout)
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, color: '#fff' }
-const HRV_PALETTE = {
- balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
- unbalanced: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
- low: 'text-red-400 bg-red-400/10 border-red-400/30',
- poor: 'text-red-400 bg-red-400/10 border-red-400/30',
-}
-
// Compact single-stat widgets. val(health, ytdStats) → display string.
const STAT_DEFS = {
stat_steps: { label: 'Steps today', accent: 'green', sub: 'goal 10,000', val: h => h.steps != null ? h.steps.toLocaleString() : '--' },
stat_resting_hr: { label: 'Resting HR', accent: 'red', val: h => formatHeartRate(h.resting_hr) },
stat_sleep: { label: 'Sleep', accent: 'default', val: h => formatSleep(h.sleep_duration_s) },
stat_vo2max: { label: 'VO₂ max', accent: 'blue', val: h => h.vo2max != null ? h.vo2max.toFixed(1) : '--', sub: h => h.fitness_age != null ? `fitness age ${h.fitness_age}` : undefined },
- stat_hrv: { label: 'HRV status', accent: 'purple', val: h => h.hrv_nightly_avg != null ? `${Math.round(h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status || undefined },
+ stat_hrv: { label: 'HRV status', accent: 'purple', val: h => h.hrv_nightly_avg != null ? `${Math.round(h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status ?
{activity.name}
+ {activity.named_route_name && ( +📍 {activity.named_route_name}
+ )}{formatDate(activity.start_time)}