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 (
+
📊
@@ -192,13 +219,36 @@ function DailySnapshot({ day, avg30, onOlder, onNewer, hasOlder, hasNewer }) {Avg HR (day)
Weight
+