Fix missing avg_hr_day/weight data; add 24hr HR chart to daily snapshot
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s

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:
2026-06-07 10:47:53 +01:00
parent a28ce0e009
commit f927e32853
5 changed files with 134 additions and 2 deletions
+24
View File
@@ -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,
+12
View File
@@ -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
+1
View File
@@ -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"),
@@ -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