HRV: plot Garmin overnight avg (weeklyAvg) not last-night; dedupe chart tooltips
This commit is contained in:
@@ -19,6 +19,7 @@ class HealthMetricOut(BaseModel):
|
|||||||
max_hr_day: Optional[float]
|
max_hr_day: Optional[float]
|
||||||
avg_hr_day: Optional[float]
|
avg_hr_day: Optional[float]
|
||||||
hrv_nightly_avg: Optional[float]
|
hrv_nightly_avg: Optional[float]
|
||||||
|
hrv_weekly_avg: Optional[float]
|
||||||
hrv_status: Optional[str]
|
hrv_status: Optional[str]
|
||||||
hrv_5min_high: Optional[float]
|
hrv_5min_high: Optional[float]
|
||||||
hrv_5min_low: Optional[float]
|
hrv_5min_low: Optional[float]
|
||||||
|
|||||||
@@ -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 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_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_baseline_upper FLOAT",
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS hrv_weekly_avg FLOAT",
|
||||||
]:
|
]:
|
||||||
await conn.execute(text(stmt))
|
await conn.execute(text(stmt))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -261,7 +261,8 @@ class HealthMetric(Base):
|
|||||||
max_hr_day = Column(Float, nullable=True)
|
max_hr_day = Column(Float, nullable=True)
|
||||||
avg_hr_day = Column(Float, nullable=True)
|
avg_hr_day = Column(Float, nullable=True)
|
||||||
hrv_status = Column(String(32), 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_high = Column(Float, nullable=True)
|
||||||
hrv_5min_low = 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_low = Column(Float, nullable=True) # Garmin balanced range lower bound (balancedLow)
|
||||||
|
|||||||
@@ -598,6 +598,7 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
|||||||
if hrv_data:
|
if hrv_data:
|
||||||
summary = hrv_data.get("hrvSummary") or 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_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"))
|
_set(row, "hrv_5min_high", summary.get("lastNight5MinHigh"))
|
||||||
status = summary.get("status")
|
status = summary.get("status")
|
||||||
if status:
|
if status:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const STAT_DEFS = {
|
|||||||
stat_resting_hr: { label: 'Resting HR', accent: 'red', val: h => formatHeartRate(h.resting_hr) },
|
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_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_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 ? <HrvBadge status={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 ? <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_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_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) : '--' },
|
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_awake_s: latest.sleep_awake_s ?? null,
|
||||||
sleep_score: pick('sleep_score'),
|
sleep_score: pick('sleep_score'),
|
||||||
hrv_nightly_avg: pick('hrv_nightly_avg'),
|
hrv_nightly_avg: pick('hrv_nightly_avg'),
|
||||||
|
hrv_weekly_avg: pick('hrv_weekly_avg'),
|
||||||
hrv_status: pick('hrv_status'),
|
hrv_status: pick('hrv_status'),
|
||||||
steps: pick('steps'),
|
steps: pick('steps'),
|
||||||
vo2max: pick('vo2max'),
|
vo2max: pick('vo2max'),
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
|
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
|
||||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||||
<span className="text-3xl font-bold text-violet-400">
|
<span className="text-3xl font-bold text-violet-400">
|
||||||
{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) : '--'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">ms</span>
|
<span className="text-sm text-gray-500">ms</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -677,6 +677,28 @@ const statusDot = (statusKey) => (props) => {
|
|||||||
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
|
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div style={{ ...tooltipStyle, padding: '6px 10px' }}>
|
||||||
|
<div style={{ color: '#9ca3af', marginBottom: 2 }}>{format(new Date(label), 'MMM d, yyyy')}</div>
|
||||||
|
{rows.map(p => (
|
||||||
|
<div key={p.dataKey} style={{ color: '#fff' }}>{formatter ? formatter(p.value) : p.value?.toFixed(1)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey, bandLowKey, bandHighKey, bandColor = '#9ca3af' }) {
|
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)
|
const vals = data.filter(d => d[dataKey] != null)
|
||||||
if (!vals.length) return (
|
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" />
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||||
tickFormatter={formatter} domain={domain} />
|
tickFormatter={formatter} domain={domain} />
|
||||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
<Tooltip content={<ChartTooltip formatter={formatter} />} />
|
||||||
formatter={(v, name) => name === '__band' ? null : [formatter ? formatter(v) : v?.toFixed(1)]} />
|
|
||||||
{hasBand && (
|
{hasBand && (
|
||||||
<Area type="monotone" dataKey="__band" stroke="none" fill={bandColor} fillOpacity={0.18}
|
<Area type="monotone" dataKey="__band" stroke="none" fill={bandColor} fillOpacity={0.18}
|
||||||
connectNulls isAnimationActive={false} legendType="none" activeDot={false} />
|
connectNulls isAnimationActive={false} legendType="none" activeDot={false} />
|
||||||
@@ -1063,14 +1084,14 @@ export default function HealthPage() {
|
|||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-medium text-gray-300">HRV (nightly avg)</h3>
|
<h3 className="text-sm font-medium text-gray-300">HRV (overnight avg)</h3>
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
<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: '#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: '#f97316' }} /> Unbalanced</span>
|
||||||
<span className="flex items-center gap-1"><span className="w-3 h-2 rounded-sm" style={{ background: '#9ca3af', opacity: 0.5 }} /> Baseline</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>
|
||||||
</div>
|
</div>
|
||||||
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
<MetricChart data={metrics} dataKey="hrv_weekly_avg" color="#8b5cf6"
|
||||||
formatter={v => `${Math.round(v)} ms`}
|
formatter={v => `${Math.round(v)} ms`}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||||
statusDotKey="hrv_status"
|
statusDotKey="hrv_status"
|
||||||
|
|||||||
Reference in New Issue
Block a user