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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user