HRV baseline band from Garmin + dashboard HRV colours + route name on recent activities + sync-now race fix
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user