Fix missing avg_hr_day/weight data; add 24hr HR chart to daily snapshot
Backend: - main.py: add ADD COLUMN IF NOT EXISTS migrations for avg_hr_day, max_hr_day, and intraday_hr (JSONB) on health_metrics — these columns were in the model but missing from existing DB instances, silently dropping all avg/max HR data. - models/user.py: add intraday_hr JSON column to HealthMetric. - garmin_connect_sync.py: fetch body composition (weight, BMI, body fat, muscle mass) via get_body_composition() per day, with stats.bodyWeight as fallback. Fetch intraday heart rate via get_heart_rates() and store non-null [epoch_ms, bpm] pairs in intraday_hr. - health.py: add GET /health-metrics/intraday?date=YYYY-MM-DD endpoint that returns the stored intraday_hr array for a specific day. Frontend (HealthPage): - Add IntradayHrChart component: AreaChart rendering the 24-hour HR trace with time-of-day x-axis. - DailySnapshot: show 24-hour HR chart (when intraday data present) above the activity strip; add weight + body fat % to the Heart & HRV card; show max HR alongside avg HR. - HealthPage: query /intraday for the selected day and pass data down. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,30 @@ async def health_summary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/intraday")
|
||||||
|
async def intraday_health(
|
||||||
|
date: str = Query(..., description="YYYY-MM-DD"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return intraday heart rate series for a specific day."""
|
||||||
|
from datetime import date as _date
|
||||||
|
from fastapi import HTTPException
|
||||||
|
try:
|
||||||
|
metric_date = _date.fromisoformat(date)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(HealthMetric).where(
|
||||||
|
HealthMetric.user_id == current_user.id,
|
||||||
|
func.date(HealthMetric.date) == metric_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
metric = result.scalar_one_or_none()
|
||||||
|
return {"hr_values": metric.intraday_hr if metric else None}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/manual")
|
@router.put("/manual")
|
||||||
async def add_manual_metric(
|
async def add_manual_metric(
|
||||||
body: dict,
|
body: dict,
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ async def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Column migration skipped: {e}")
|
print(f"Column migration skipped: {e}")
|
||||||
|
|
||||||
|
# health_metrics columns added after initial creation
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
for stmt in [
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS avg_hr_day FLOAT",
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT",
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
|
||||||
|
]:
|
||||||
|
await conn.execute(text(stmt))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"health_metrics column migration skipped: {e}")
|
||||||
|
|
||||||
# Replace the all-columns unique constraint on personal_records with a partial
|
# Replace the all-columns unique constraint on personal_records with a partial
|
||||||
# index (only current records must be unique per user/sport/distance).
|
# index (only current records must be unique per user/sport/distance).
|
||||||
# The old constraint also covered is_current_record=False rows, causing
|
# The old constraint also covered is_current_record=False rows, causing
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ class HealthMetric(Base):
|
|||||||
active_calories = Column(Float, nullable=True)
|
active_calories = Column(Float, nullable=True)
|
||||||
total_calories = Column(Float, nullable=True)
|
total_calories = Column(Float, nullable=True)
|
||||||
spo2_avg = Column(Float, nullable=True)
|
spo2_avg = Column(Float, nullable=True)
|
||||||
|
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
||||||
|
|||||||
@@ -209,9 +209,47 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
|
|||||||
stats = _safe(garmin.get_stats, day_str)
|
stats = _safe(garmin.get_stats, day_str)
|
||||||
sleep_data = _safe(garmin.get_sleep_data, day_str)
|
sleep_data = _safe(garmin.get_sleep_data, day_str)
|
||||||
hrv_data = _safe(garmin.get_hrv_data, day_str)
|
hrv_data = _safe(garmin.get_hrv_data, day_str)
|
||||||
|
# Intraday HR (requires display_name; skip gracefully if absent)
|
||||||
|
hr_raw = _safe(garmin.get_heart_rates, day_str) if garmin.display_name else None
|
||||||
|
bc_data = _safe(garmin.get_body_composition, day_str, day_str)
|
||||||
_time.sleep(0.25) # avoid hammering Garmin's wellness API
|
_time.sleep(0.25) # avoid hammering Garmin's wellness API
|
||||||
|
|
||||||
row = _parse_day(stats, sleep_data, hrv_data)
|
row = _parse_day(stats, sleep_data, hrv_data)
|
||||||
|
|
||||||
|
# Weight + body composition from weight service (more reliable than stats)
|
||||||
|
if bc_data:
|
||||||
|
entries = (bc_data.get("dateWeightList")
|
||||||
|
or bc_data.get("allWeightMetrics")
|
||||||
|
or bc_data.get("weightList") or [])
|
||||||
|
if entries:
|
||||||
|
e = entries[0]
|
||||||
|
bw = e.get("weight")
|
||||||
|
if bw and float(bw) > 0:
|
||||||
|
bwf = float(bw)
|
||||||
|
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2))
|
||||||
|
if e.get("bmi"):
|
||||||
|
_set(row, "bmi", float(e["bmi"]))
|
||||||
|
if e.get("bodyFat"):
|
||||||
|
_set(row, "body_fat_pct", float(e["bodyFat"]))
|
||||||
|
mm = e.get("muscleMass")
|
||||||
|
if mm and float(mm) > 0:
|
||||||
|
mmf = float(mm)
|
||||||
|
_set(row, "muscle_mass_kg", round(mmf / 1000 if mmf > 300 else mmf, 2))
|
||||||
|
|
||||||
|
# Weight from daily stats as fallback (present when Garmin scale is used)
|
||||||
|
if stats and "weight_kg" not in row:
|
||||||
|
bw = stats.get("bodyWeight")
|
||||||
|
if bw and float(bw) > 0:
|
||||||
|
bwf = float(bw)
|
||||||
|
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2))
|
||||||
|
|
||||||
|
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs
|
||||||
|
if hr_raw:
|
||||||
|
raw_vals = hr_raw.get("heartRateValues") or []
|
||||||
|
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
|
||||||
|
if intraday:
|
||||||
|
row["intraday_hr"] = intraday
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,33 @@ function fmtTime(ts) {
|
|||||||
return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IntradayHrChart({ values }) {
|
||||||
|
if (!values?.length) return null
|
||||||
|
const data = values.map(([ts, hr]) => ({ t: ts, hr }))
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad-intraday-hr" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
|
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
|
interval={Math.max(1, Math.floor(data.length / 6))} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||||
|
tickFormatter={v => Math.round(v)} domain={['auto', 'auto']} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle}
|
||||||
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
|
formatter={v => [`${Math.round(v)} bpm`, 'HR']} />
|
||||||
|
<Area type="monotone" dataKey="hr" stroke="#f43f5e" strokeWidth={1.5}
|
||||||
|
fill="url(#grad-intraday-hr)" dot={false} isAnimationActive={false} connectNulls={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SleepStagesBar({ deep, light, rem, awake }) {
|
function SleepStagesBar({ deep, light, rem, awake }) {
|
||||||
const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
|
const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
|
||||||
if (!total) return null
|
if (!total) return null
|
||||||
@@ -71,7 +98,7 @@ function NavArrow({ onClick, disabled, children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DailySnapshot({ day, avg30, onOlder, onNewer, hasOlder, hasNewer }) {
|
function DailySnapshot({ day, avg30, intradayHr, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||||
if (!day) return (
|
if (!day) return (
|
||||||
<div className="text-center py-10 text-gray-600">
|
<div className="text-center py-10 text-gray-600">
|
||||||
<p className="text-3xl mb-2">📊</p>
|
<p className="text-3xl mb-2">📊</p>
|
||||||
@@ -192,13 +219,36 @@ function DailySnapshot({ day, avg30, onOlder, onNewer, hasOlder, hasNewer }) {
|
|||||||
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-xl font-semibold text-orange-400">{Math.round(day.avg_hr_day)}</span>
|
<span className="text-xl font-semibold text-orange-400">{Math.round(day.avg_hr_day)}</span>
|
||||||
<span className="text-xs text-gray-500">bpm</span>
|
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max bpm</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{day.weight_kg && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
||||||
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||||
|
<span className="text-xl font-semibold text-emerald-400">{day.weight_kg.toFixed(1)}</span>
|
||||||
|
<span className="text-xs text-gray-500">kg</span>
|
||||||
|
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 24-hour heart rate chart */}
|
||||||
|
{intradayHr?.length > 0 && (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">24-hour Heart Rate</h3>
|
||||||
|
{day.avg_hr_day && (
|
||||||
|
<span className="text-xs text-gray-500">avg {Math.round(day.avg_hr_day)} bpm</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<IntradayHrChart values={intradayHr} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Activity strip */}
|
{/* Activity strip */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
|
||||||
@@ -386,6 +436,12 @@ export default function HealthPage() {
|
|||||||
.then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
|
.then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: intradayData } = useQuery({
|
||||||
|
queryKey: ['health-intraday', selectedDay?.date],
|
||||||
|
queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data),
|
||||||
|
enabled: !!selectedDay?.date,
|
||||||
|
})
|
||||||
|
|
||||||
// Trend window (changes with range selector).
|
// Trend window (changes with range selector).
|
||||||
// Dates normalised to YYYY-MM-DD so XAxis values match ReferenceLine x.
|
// Dates normalised to YYYY-MM-DD so XAxis values match ReferenceLine x.
|
||||||
const { data: rawMetrics, isLoading } = useQuery({
|
const { data: rawMetrics, isLoading } = useQuery({
|
||||||
@@ -433,6 +489,7 @@ export default function HealthPage() {
|
|||||||
<DailySnapshot
|
<DailySnapshot
|
||||||
day={selectedDay}
|
day={selectedDay}
|
||||||
avg30={summary?.avg_30d}
|
avg30={summary?.avg_30d}
|
||||||
|
intradayHr={intradayData?.hr_values}
|
||||||
onOlder={goOlder}
|
onOlder={goOlder}
|
||||||
onNewer={goNewer}
|
onNewer={goNewer}
|
||||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||||
|
|||||||
Reference in New Issue
Block a user