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 ? : undefined }, stat_running: { label: 'Running this year', accent: 'blue', val: (h, y) => y ? `${y.running_km.toFixed(0)} km` : '--' }, stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y) => y ? `${y.cycling_km.toFixed(0)} km` : '--' }, stat_stress: { label: 'Stress', accent: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' }, @@ -410,6 +404,9 @@ function RecentActivities({ activities }) { {sportIcon(activity.sport_type)}

{activity.name}

+ {activity.named_route_name && ( +

📍 {activity.named_route_name}

+ )}

{formatDate(activity.start_time)}

diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index cbf2671..2002c48 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -8,6 +8,7 @@ import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns' import api from '../utils/api' import { formatSleep, sportIcon } from '../utils/format' import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery' +import HrvBadge from '../components/ui/HrvBadge' const RANGES = [ { label: '1W', days: 7 }, @@ -414,18 +415,6 @@ function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) { ) } -function HrvBadge({ status }) { - if (!status) return null - const 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', - } - const cls = palette[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30' - return {status} -} - function NavArrow({ onClick, disabled, children }) { return (
`${Math.round(v)} ms`} selectedDate={selDateForCharts} onDayClick={handleDayClick} statusDotKey="hrv_status" - referenceLines={[ - { y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } }, - { y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } }, - ]} + bandLowKey="hrv_baseline_low" bandHighKey="hrv_baseline_upper" bandColor="#9ca3af" />