diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 9898552..ed22e14 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -19,6 +19,7 @@ class HealthMetricOut(BaseModel): max_hr_day: Optional[float] avg_hr_day: Optional[float] hrv_nightly_avg: Optional[float] + hrv_weekly_avg: Optional[float] hrv_status: Optional[str] hrv_5min_high: Optional[float] hrv_5min_low: Optional[float] diff --git a/backend/app/main.py b/backend/app/main.py index 3182edc..d0dc453 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -70,6 +70,7 @@ async def init_db(): "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", + "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS hrv_weekly_avg 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 215b358..bfdf373 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -261,7 +261,8 @@ class HealthMetric(Base): max_hr_day = Column(Float, nullable=True) avg_hr_day = Column(Float, nullable=True) hrv_status = Column(String(32), nullable=True) - hrv_nightly_avg = Column(Float, nullable=True) + hrv_nightly_avg = Column(Float, nullable=True) # last single night's avg (Garmin lastNightAvg) + hrv_weekly_avg = Column(Float, nullable=True) # overnight/weekly avg Garmin plots on its HRV Status chart 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) diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py index 6020fc9..cb2e847 100644 --- a/backend/app/services/garmin_connect_sync.py +++ b/backend/app/services/garmin_connect_sync.py @@ -598,6 +598,7 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict: if hrv_data: summary = hrv_data.get("hrvSummary") or hrv_data _set(row, "hrv_nightly_avg", summary.get("lastNight") or summary.get("lastNightAvg")) + _set(row, "hrv_weekly_avg", summary.get("weeklyAvg")) _set(row, "hrv_5min_high", summary.get("lastNight5MinHigh")) status = summary.get("status") if status: diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index faa585b..cfec754 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -30,7 +30,7 @@ const STAT_DEFS = { 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_weekly_avg ?? h.hrv_nightly_avg) != null ? `${Math.round(h.hrv_weekly_avg ?? 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) : '--' }, @@ -480,6 +480,7 @@ export default function DashboardPage() { sleep_awake_s: latest.sleep_awake_s ?? null, sleep_score: pick('sleep_score'), hrv_nightly_avg: pick('hrv_nightly_avg'), + hrv_weekly_avg: pick('hrv_weekly_avg'), hrv_status: pick('hrv_status'), steps: pick('steps'), vo2max: pick('vo2max'), diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 2002c48..6b18d27 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -547,7 +547,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb

HRV

- {day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'} + {(day.hrv_weekly_avg ?? day.hrv_nightly_avg) ? Math.round(day.hrv_weekly_avg ?? day.hrv_nightly_avg) : '--'} ms
@@ -677,6 +677,28 @@ const statusDot = (statusKey) => (props) => { return } +// Custom tooltip: collapses the dashed gap-bridging Line and the solid Area +// (same dataKey) into one row, and never shows the helper baseline band. +function ChartTooltip({ active, payload, label, formatter }) { + if (!active || !payload?.length) return null + const seen = new Set() + const rows = [] + for (const p of payload) { + if (p.dataKey === '__band' || p.value == null || seen.has(p.dataKey)) continue + seen.add(p.dataKey) + rows.push(p) + } + if (!rows.length) return null + return ( +
+
{format(new Date(label), 'MMM d, yyyy')}
+ {rows.map(p => ( +
{formatter ? formatter(p.value) : p.value?.toFixed(1)}
+ ))} +
+ ) +} + 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 ( @@ -713,8 +735,7 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> - format(new Date(d), 'MMM d, yyyy')} - formatter={(v, name) => name === '__band' ? null : [formatter ? formatter(v) : v?.toFixed(1)]} /> + } /> {hasBand && ( @@ -1063,14 +1084,14 @@ export default function HealthPage() {
-

HRV (nightly avg)

+

HRV (overnight avg)

Balanced Unbalanced Baseline
- `${Math.round(v)} ms`} selectedDate={selDateForCharts} onDayClick={handleDayClick} statusDotKey="hrv_status"