diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 120480c..faecf55 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -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") async def add_manual_metric( body: dict, diff --git a/backend/app/main.py b/backend/app/main.py index a3dff39..74f8d9d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -50,6 +50,18 @@ async def init_db(): except Exception as 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 # index (only current records must be unique per user/sport/distance). # The old constraint also covered is_current_record=False rows, causing diff --git a/backend/app/models/user.py b/backend/app/models/user.py index bb1a56f..3e86db4 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -243,6 +243,7 @@ class HealthMetric(Base): active_calories = Column(Float, nullable=True) total_calories = 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__ = ( UniqueConstraint("user_id", "date", name="uq_health_user_date"), diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py index 8d65129..cd460b3 100644 --- a/backend/app/services/garmin_connect_sync.py +++ b/backend/app/services/garmin_connect_sync.py @@ -209,9 +209,47 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, stats = _safe(garmin.get_stats, day_str) sleep_data = _safe(garmin.get_sleep_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 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: continue diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 91c7252..717d220 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -31,6 +31,33 @@ function fmtTime(ts) { 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 ( + + + + + + + + + format(new Date(ts), 'HH:mm')} + interval={Math.max(1, Math.floor(data.length / 6))} /> + Math.round(v)} domain={['auto', 'auto']} /> + format(new Date(ts), 'HH:mm')} + formatter={v => [`${Math.round(v)} bpm`, 'HR']} /> + + + + ) +} + function SleepStagesBar({ deep, light, rem, awake }) { const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0) 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 (

📊

@@ -192,13 +219,36 @@ function DailySnapshot({ day, avg30, onOlder, onNewer, hasOlder, hasNewer }) {

Avg HR (day)

{Math.round(day.avg_hr_day)} - bpm + {day.max_hr_day && / {Math.round(day.max_hr_day)} max bpm} +
+
+ )} + {day.weight_kg && ( +
+

Weight

+
+ {day.weight_kg.toFixed(1)} + kg + {day.body_fat_pct && {day.body_fat_pct.toFixed(1)}% fat}
)} + {/* 24-hour heart rate chart */} + {intradayHr?.length > 0 && ( +
+
+

24-hour Heart Rate

+ {day.avg_hr_day && ( + avg {Math.round(day.avg_hr_day)} bpm + )} +
+ +
+ )} + {/* Activity strip */}
@@ -386,6 +436,12 @@ export default function HealthPage() { .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). // Dates normalised to YYYY-MM-DD so XAxis values match ReferenceLine x. const { data: rawMetrics, isLoading } = useQuery({ @@ -433,6 +489,7 @@ export default function HealthPage() { = 0 && selectedIdx < allDaysSorted.length - 1}