HRV baseline band from Garmin + dashboard HRV colours + route name on recent activities + sync-now race fix
Build and push images / validate (push) Successful in 7s
Build and push images / build-backend (push) Successful in 1m9s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s

This commit is contained in:
2026-06-16 00:13:16 +01:00
parent e7123ee5db
commit d01f66223b
10 changed files with 98 additions and 45 deletions
+4 -2
View File
@@ -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,
)
+2
View File
@@ -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]
+2
View File
@@ -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:
+12
View File
@@ -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)
@@ -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
+5 -10
View File
@@ -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:
+15
View File
@@ -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 <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
}
+30 -5
View File
@@ -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()
+5 -8
View File
@@ -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 ? <HrvBadge status={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 }) {
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
{activity.named_route_name && (
<p className="text-xs text-blue-400 truncate">📍 {activity.named_route_name}</p>
)}
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
</div>
<div className="text-right text-sm">
+19 -20
View File
@@ -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 <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
}
function NavArrow({ onClick, disabled, children }) {
return (
<button
@@ -688,15 +677,24 @@ const statusDot = (statusKey) => (props) => {
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
}
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey }) {
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey, bandLowKey, bandHighKey, bandColor = '#9ca3af' }) {
const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return (
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
)
// Range band (e.g. Garmin's HRV baseline): Recharts renders an Area as a band
// when its dataKey resolves to a [low, high] pair.
const hasBand = bandLowKey && bandHighKey
const chartData = hasBand
? data.map(d => ({
...d,
__band: (d[bandLowKey] != null && d[bandHighKey] != null) ? [d[bandLowKey], d[bandHighKey]] : null,
}))
: data
return (
<ResponsiveContainer width="100%" height={height}>
<ComposedChart
data={data}
data={chartData}
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => {
@@ -716,7 +714,11 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={formatter} domain={domain} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
formatter={(v, name) => name === '__band' ? null : [formatter ? formatter(v) : v?.toFixed(1)]} />
{hasBand && (
<Area type="monotone" dataKey="__band" stroke="none" fill={bandColor} fillOpacity={0.18}
connectNulls isAnimationActive={false} legendType="none" activeDot={false} />
)}
{selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
@@ -1065,17 +1067,14 @@ export default function HealthPage() {
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} /> Balanced</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#f97316' }} /> Unbalanced</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#ef4444' }} /> Low</span>
<span className="flex items-center gap-1"><span className="w-3 h-2 rounded-sm" style={{ background: '#9ca3af', opacity: 0.5 }} /> Baseline</span>
</div>
</div>
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
formatter={v => `${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"
/>
</div>