From a3540fd68b4d0adfd1300fa8cd039b9acc3c85c9 Mon Sep 17 00:00:00 2001 From: Mukkesh Ganesh Date: Mon, 29 Jun 2026 16:19:38 -0700 Subject: [PATCH 1/2] Add heart rate metric to dashboard --- scripts/aggregate.py | 9 +++++ scripts/generate_heatmaps.py | 21 ++++++----- scripts/normalize.py | 13 +++++++ scripts/provider_fields.py | 14 ++++++++ scripts/sync_garmin.py | 16 +++++++++ scripts/utils.py | 4 +++ site/app.js | 70 ++++++++++++++++++++++++++++++++++-- 7 files changed, 136 insertions(+), 11 deletions(-) diff --git a/scripts/aggregate.py b/scripts/aggregate.py index b8953d6475..4e72a0e795 100644 --- a/scripts/aggregate.py +++ b/scripts/aggregate.py @@ -37,12 +37,19 @@ def aggregate(): "distance": 0.0, "moving_time": 0.0, "elevation_gain": 0.0, + "hr_weighted_sum": 0.0, + "hr_time": 0.0, "activity_ids": [], } entry["count"] += 1 entry["distance"] += float(item.get("distance", 0.0)) entry["moving_time"] += float(item.get("moving_time", 0.0)) entry["elevation_gain"] += float(item.get("elevation_gain", 0.0)) + item_hr = float(item.get("heart_rate", 0.0) or 0.0) + item_time = float(item.get("moving_time", 0.0) or 0.0) + if item_hr > 0.0 and item_time > 0.0: + entry["hr_weighted_sum"] += item_hr * item_time + entry["hr_time"] += item_time entry["activity_ids"].append(item.get("id")) data[year][activity_type][date] = entry @@ -50,6 +57,8 @@ def aggregate(): for type_data in year_data.values(): for entry in type_data.values(): entry["activity_ids"] = sorted(entry["activity_ids"]) + if entry.get("hr_time", 0.0) > 0.0: + entry["heart_rate"] = entry["hr_weighted_sum"] / entry["hr_time"] output = { "generated_at": utc_now().isoformat(), diff --git a/scripts/generate_heatmaps.py b/scripts/generate_heatmaps.py index b93a4a4623..00d3003f52 100644 --- a/scripts/generate_heatmaps.py +++ b/scripts/generate_heatmaps.py @@ -13,6 +13,7 @@ format_distance, format_duration, format_elevation, + format_heart_rate, load_config, normalize_source, parse_iso_datetime, @@ -113,14 +114,16 @@ def _build_title(date_str: str, entry: Dict, units: Dict[str, str]) -> str: distance = format_distance(entry.get("distance", 0.0), units["distance"]) duration = format_duration(entry.get("moving_time", 0.0)) elevation = format_elevation(entry.get("elevation_gain", 0.0), units["elevation"]) - - return ( - f"{date_str}\n" - f"{count} workout{'s' if count != 1 else ''}\n" - f"Distance: {distance}\n" - f"Duration: {duration}\n" - f"Elevation: {elevation}" - ) + lines = [ + f"{date_str}", + f"{count} workout{'s' if count != 1 else ''}", + f"Distance: {distance}", + f"Duration: {duration}", + f"Elevation: {elevation}", + ] + if float(entry.get("hr_time", 0.0) or 0.0) > 0.0: + lines.append(f"Heart rate: {format_heart_rate(entry.get('heart_rate', 0.0))}") + return "\n".join(lines) def _color_scale(accent: str) -> List[str]: @@ -374,6 +377,8 @@ def _svg_for_year( "distance": 0.0, "moving_time": 0.0, "elevation_gain": 0.0, + "hr_weighted_sum": 0.0, + "hr_time": 0.0, "activity_ids": [], }) count = int(entry.get("count", 0)) diff --git a/scripts/normalize.py b/scripts/normalize.py index 2aeeb6f09f..a48143bc15 100644 --- a/scripts/normalize.py +++ b/scripts/normalize.py @@ -7,6 +7,7 @@ coalesce as _shared_coalesce, get_nested as _shared_get_nested, pick_duration_seconds as _shared_pick_duration_seconds, + pick_heart_rate as _shared_pick_heart_rate, ) from utils import ensure_dir, load_config, normalize_source, parse_iso_datetime, raw_activity_dir, read_json, write_json @@ -28,6 +29,10 @@ def _pick_duration_seconds(*values: Any) -> float: return _shared_pick_duration_seconds(*values) +def _pick_heart_rate(*values: Any) -> float: + return _shared_pick_heart_rate(*values) + + def _duration_candidates(activity: Dict[str, Any]) -> List[Any]: return [ activity.get("moving_time"), @@ -82,6 +87,12 @@ def _normalize_activity(activity: Dict, type_aliases: Dict[str, str], source: st activity.get("elevationGain"), activity.get("totalElevationGain"), ) + heart_rate = _pick_heart_rate( + activity.get("average_heartrate"), + activity.get("averageHR"), + _get_nested(activity, ["summaryDTO", "averageHR"]), + _get_nested(activity, ["activitySummary", "averageHR"]), + ) activity_name = str(_coalesce(activity.get("name"), activity.get("activityName"), "") or "").strip() normalized = { @@ -96,6 +107,8 @@ def _normalize_activity(activity: Dict, type_aliases: Dict[str, str], source: st "moving_time": _safe_float(moving_time), "elevation_gain": _safe_float(elevation_gain), } + if heart_rate > 0.0: + normalized["heart_rate"] = heart_rate if activity_name: normalized["name"] = activity_name return normalized diff --git a/scripts/provider_fields.py b/scripts/provider_fields.py index e05eb177e5..75aac42efa 100644 --- a/scripts/provider_fields.py +++ b/scripts/provider_fields.py @@ -32,3 +32,17 @@ def pick_duration_seconds(*values: Any) -> float: if number > 0: return number return first_numeric if first_numeric is not None else 0.0 + + +def pick_heart_rate(*values: Any) -> float: + """Return the first plausible resting/active HR value (0 < hr <= 255).""" + for value in values: + if value in (None, "", []): + continue + try: + number = float(value) + except (TypeError, ValueError): + continue + if 0.0 < number <= 255.0: + return number + return 0.0 diff --git a/scripts/sync_garmin.py b/scripts/sync_garmin.py index 7edbd91a8f..89c979dfdb 100644 --- a/scripts/sync_garmin.py +++ b/scripts/sync_garmin.py @@ -17,6 +17,7 @@ coalesce as _shared_coalesce, get_nested as _shared_get_nested, pick_duration_seconds as _shared_pick_duration_seconds, + pick_heart_rate as _shared_pick_heart_rate, ) from sync_scope import ( activity_scope_from_config, @@ -88,6 +89,10 @@ def _pick_duration_seconds(*values: Any) -> float: return _shared_pick_duration_seconds(*values) +def _pick_heart_rate(*values: Any) -> float: + return _shared_pick_heart_rate(*values) + + def _get_nested(payload: Dict[str, Any], keys: List[str]) -> Any: return _shared_get_nested(payload, keys) @@ -127,6 +132,15 @@ def _normalize_activity(activity: Dict[str, Any]) -> Dict[str, Any]: activity.get("total_elevation_gain"), ) distance = _coalesce(activity.get("distance"), activity.get("totalDistance"), 0.0) + heart_rate = _pick_heart_rate( + activity.get("averageHR"), + activity.get("averageHeartRate"), + activity.get("average_heartrate"), + _get_nested(activity, ["summaryDTO", "averageHR"]), + _get_nested(activity, ["summaryDTO", "averageHeartRate"]), + _get_nested(activity, ["activitySummary", "averageHR"]), + _get_nested(activity, ["activitySummary", "averageHeartRate"]), + ) activity_name = str( _coalesce( activity.get("activityName"), @@ -155,6 +169,8 @@ def _normalize_activity(activity: Dict[str, Any]) -> Dict[str, Any]: "total_elevation_gain": _safe_float(elevation_gain, 0.0), "provider": "garmin", } + if heart_rate > 0.0: + normalized["average_heartrate"] = heart_rate if activity_name: normalized["name"] = activity_name return normalized diff --git a/scripts/utils.py b/scripts/utils.py index 0064b78c9b..293ef3aedc 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -113,3 +113,7 @@ def format_elevation(meters: float, unit: str) -> str: return f"{meters:.0f} m" feet = meters * 3.28084 return f"{feet:.0f} ft" + + +def format_heart_rate(bpm: float) -> str: + return f"{int(round(bpm))} bpm" diff --git a/site/app.js b/site/app.js index d096a75cb3..7df72dfc53 100644 --- a/site/app.js +++ b/site/app.js @@ -2389,6 +2389,10 @@ function formatElevation(meters, units) { return `${formatNumber(Math.round(meters * 3.28084), 0)} ft`; } +function formatHeartRate(bpm) { + return `${formatNumber(Math.round(bpm), 0)} bpm`; +} + function buildYearMetricStatItems(totals, units) { return [ { @@ -2413,6 +2417,14 @@ function buildYearMetricStatItems(totals, units) { : STAT_PLACEHOLDER, filterable: totals.elevation > 0, }, + { + key: "heart_rate", + label: "Avg Heart Rate", + value: totals.heart_rate > 0 + ? formatHeartRate(totals.heart_rate) + : STAT_PLACEHOLDER, + filterable: totals.heart_rate > 0, + }, ]; } @@ -2420,6 +2432,7 @@ const FREQUENCY_METRIC_ITEMS = [ { key: "distance", label: "Distance" }, { key: "moving_time", label: "Time" }, { key: "elevation_gain", label: "Elevation" }, + { key: "heart_rate", label: "Heart Rate" }, ]; const METRIC_LABEL_BY_KEY = Object.freeze({ [ACTIVE_DAYS_METRIC_KEY]: "Active Days", @@ -2427,12 +2440,14 @@ const METRIC_LABEL_BY_KEY = Object.freeze({ distance: "Distance", moving_time: "Time", elevation_gain: "Elevation", + heart_rate: "Heart Rate", }); const FREQUENCY_METRIC_UNAVAILABLE_REASON_BY_KEY = { distance: "No distance data in current selection.", moving_time: "No time data in current selection.", elevation_gain: "No elevation data in current selection.", + heart_rate: "No heart rate data in current selection.", }; function getFrequencyMetricUnavailableReason(metricKey, metricLabel) { @@ -2453,6 +2468,9 @@ function formatMetricTotal(metricKey, value, units) { if (metricKey === "elevation_gain") { return formatElevation(value, units || { elevation: "ft" }); } + if (metricKey === "heart_rate") { + return formatHeartRate(value); + } return formatNumber(value, 0); } @@ -2589,6 +2607,7 @@ function formatTooltipMetricLines(entry, units, prefix = "") { const distanceMeters = Number(entry?.distance || 0); const elevationMeters = Number(entry?.elevation_gain || 0); const durationSeconds = Number(entry?.moving_time || 0); + const heartRate = Number(entry?.heart_rate || 0); const distanceUnits = units?.distance === "km" ? "km" : "mi"; const elevationUnits = units?.elevation === "m" ? "m" : "ft"; @@ -2607,6 +2626,9 @@ function formatTooltipMetricLines(entry, units, prefix = "") { if (durationSeconds > 0) { lines.push(createTooltipTextLine(`${prefix}Duration: ${formatTooltipDuration(durationSeconds)}`)); } + if (heartRate > 0) { + lines.push(createTooltipTextLine(`${prefix}Heart rate: ${Math.round(heartRate)} bpm`)); + } return lines; } @@ -2872,6 +2894,7 @@ function buildCombinedTypeDetailsByDate(payload, types, years) { distance: Number(dayEntry?.distance || 0), moving_time: Number(dayEntry?.moving_time || 0), elevation_gain: Number(dayEntry?.elevation_gain || 0), + heart_rate: Number(dayEntry?.heart_rate || 0), }; }); }); @@ -3010,6 +3033,9 @@ function buildSummary( distance: 0, moving_time: 0, elevation: 0, + hr_weighted_sum: 0, + hr_time: 0, + heart_rate: 0, }; const typeTotals = {}; const selectedTypeSet = new Set(types); @@ -3041,6 +3067,8 @@ function buildSummary( totals.distance += entry.distance || 0; totals.moving_time += entry.moving_time || 0; totals.elevation += entry.elevation_gain || 0; + totals.hr_weighted_sum += Number(entry.hr_weighted_sum || 0); + totals.hr_time += Number(entry.hr_time || 0); } if (includeTypeCardCount) { typeTotals[type].count += entry.count || 0; @@ -3050,6 +3078,7 @@ function buildSummary( }); visibleTypeCardsList.sort((a, b) => (typeTotals[b]?.count || 0) - (typeTotals[a]?.count || 0)); + totals.heart_rate = totals.hr_time > 0 ? totals.hr_weighted_sum / totals.hr_time : 0; const elapsedDays = years.reduce( (sum, year) => sum + getElapsedDayCountForYear(Number(year)), 0, @@ -3102,6 +3131,14 @@ function buildSummary( metricKey: "elevation_gain", filterable: totals.elevation > 0, }, + { + title: "Avg HR", + value: totals.heart_rate > 0 + ? formatHeartRate(totals.heart_rate) + : STAT_PLACEHOLDER, + metricKey: "heart_rate", + filterable: totals.heart_rate > 0, + }, ); cards.forEach((card) => { @@ -3260,6 +3297,9 @@ function buildHeatmapArea(aggregates, year, units, colors, type, layout, options distance: 0, moving_time: 0, elevation_gain: 0, + hr_weighted_sum: 0, + hr_time: 0, + heart_rate: 0, activity_ids: [], }; @@ -3556,6 +3596,7 @@ function buildCard(type, year, aggregates, units, options = {}) { distance: 0, moving_time: 0, elevation_gain: 0, + heart_rate: 0, }; const layout = getLayout(); const heatmapOptions = { @@ -3578,6 +3619,9 @@ function buildCard(type, year, aggregates, units, options = {}) { distance: 0, moving_time: 0, elevation: 0, + hr_weighted_sum: 0, + hr_time: 0, + heart_rate: 0, }; const todayDateKey = getLocalTodayDateKey(); let elapsedActiveDays = 0; @@ -3586,13 +3630,17 @@ function buildCard(type, year, aggregates, units, options = {}) { totals.distance += entry.distance || 0; totals.moving_time += entry.moving_time || 0; totals.elevation += entry.elevation_gain || 0; + totals.hr_weighted_sum += Number(entry.hr_weighted_sum || 0); + totals.hr_time += Number(entry.hr_time || 0); metricMaxByKey.distance = Math.max(metricMaxByKey.distance, Number(entry.distance || 0)); metricMaxByKey.moving_time = Math.max(metricMaxByKey.moving_time, Number(entry.moving_time || 0)); metricMaxByKey.elevation_gain = Math.max(metricMaxByKey.elevation_gain, Number(entry.elevation_gain || 0)); + metricMaxByKey.heart_rate = Math.max(metricMaxByKey.heart_rate, Number(entry.heart_rate || 0)); if ((entry?.count || 0) > 0 && isDateKeyElapsed(dateStr, todayDateKey)) { elapsedActiveDays += 1; } }); + totals.heart_rate = totals.hr_time > 0 ? totals.hr_weighted_sum / totals.hr_time : 0; const elapsedDaysInYear = getElapsedDayCountForYear(year); const daysOffInYear = Math.max(0, elapsedDaysInYear - elapsedActiveDays); metricMaxByKey[ACTIVE_DAYS_METRIC_KEY] = totals.count > 0 ? 1 : 0; @@ -3721,6 +3769,8 @@ function combineYearAggregates(yearData, types) { distance: 0, moving_time: 0, elevation_gain: 0, + hr_weighted_sum: 0, + hr_time: 0, types: new Set(), }; } @@ -3728,6 +3778,8 @@ function combineYearAggregates(yearData, types) { combined[dateStr].distance += entry.distance || 0; combined[dateStr].moving_time += entry.moving_time || 0; combined[dateStr].elevation_gain += entry.elevation_gain || 0; + combined[dateStr].hr_weighted_sum += Number(entry.hr_weighted_sum || 0); + combined[dateStr].hr_time += Number(entry.hr_time || 0); if ((entry.count || 0) > 0) { combined[dateStr].types.add(type); } @@ -3741,6 +3793,9 @@ function combineYearAggregates(yearData, types) { distance: entry.distance, moving_time: entry.moving_time, elevation_gain: entry.elevation_gain, + hr_weighted_sum: entry.hr_weighted_sum, + hr_time: entry.hr_time, + heart_rate: entry.hr_time > 0 ? entry.hr_weighted_sum / entry.hr_time : 0, types: Array.from(entry.types), }; }); @@ -3878,6 +3933,7 @@ function buildStatsOverview(payload, types, years, color, options = {}) { ? dayValue / dayEntryCount : 0; }; + const dayHeartRate = Number(dayEntry?.heart_rate || 0); return { dateKey: dateStr, date, @@ -3892,6 +3948,7 @@ function buildStatsOverview(payload, types, years, color, options = {}) { distance: perActivityMetricValue("distance"), moving_time: perActivityMetricValue("moving_time"), elevation_gain: perActivityMetricValue("elevation_gain"), + heart_rate: Number.isFinite(dayHeartRate) && dayHeartRate > 0 ? dayHeartRate : 0, }; }) .filter(Boolean); @@ -4031,6 +4088,7 @@ function buildStatsOverview(payload, types, years, color, options = {}) { distance: activities.reduce((sum, activity) => sum + Number(activity.distance || 0), 0), moving_time: activities.reduce((sum, activity) => sum + Number(activity.moving_time || 0), 0), elevation_gain: activities.reduce((sum, activity) => sum + Number(activity.elevation_gain || 0), 0), + heart_rate: activities.reduce((sum, activity) => sum + Number(activity.heart_rate || 0), 0), }; const metricItems = FREQUENCY_METRIC_ITEMS.map((item) => ({ key: item.key, @@ -4166,14 +4224,20 @@ function buildStatsOverview(payload, types, years, color, options = {}) { const activeFact = factItems.find((item) => item.key === activeFactKey) || null; const matrixData = buildFrequencyData(activeFact?.filter, activeMetricKey); const metricLabel = activeMetricKey ? (METRIC_LABEL_BY_KEY[activeMetricKey] || "Metric") : ""; - const formatTooltipValue = (value) => { + const formatTooltipValue = (value, breakdown) => { if (!activeMetricKey) return ""; - return `${metricLabel}: ${formatMetricTotal(activeMetricKey, value, units)}`; + let displayValue = value; + if (activeMetricKey === "heart_rate") { + const cellActivityCount = Object.values(breakdown?.typeCounts || {}) + .reduce((sum, count) => sum + count, 0); + displayValue = cellActivityCount > 0 ? value / cellActivityCount : 0; + } + return `${metricLabel}: ${formatMetricTotal(activeMetricKey, displayValue, units)}`; }; const formatMatrixTooltip = (year, label, value, breakdown) => { const lines = [`${year} ยท ${label}`]; if (activeMetricKey) { - lines.push(formatTooltipValue(value)); + lines.push(formatTooltipValue(value, breakdown)); if (activeMetricKey !== DAYS_OFF_METRIC_KEY) { const activityTotal = Object.values(breakdown?.typeCounts || {}) .reduce((sum, count) => sum + count, 0); From 59eb0609e69989afdd868ef2fab030d731f2b88f Mon Sep 17 00:00:00 2001 From: Mukkesh Ganesh Date: Mon, 29 Jun 2026 16:20:12 -0700 Subject: [PATCH 2/2] Add heart rate test coverage --- .../test_generate_heatmaps_render_contract.py | 43 +++++ tests/test_heart_rate_contract.py | 66 ++++++++ tests/test_heart_rate_pipeline.py | 126 +++++++++++++++ tests/test_normalize_and_aggregate.py | 149 ++++++++++++++++++ tests/test_provider_fields.py | 9 ++ tests/test_sync_garmin_normalize.py | 80 ++++++++++ tests/test_utils.py | 5 + 7 files changed, 478 insertions(+) create mode 100644 tests/test_heart_rate_contract.py create mode 100644 tests/test_heart_rate_pipeline.py create mode 100644 tests/test_sync_garmin_normalize.py diff --git a/tests/test_generate_heatmaps_render_contract.py b/tests/test_generate_heatmaps_render_contract.py index e0de1024e2..d5fbcc592a 100644 --- a/tests/test_generate_heatmaps_render_contract.py +++ b/tests/test_generate_heatmaps_render_contract.py @@ -113,6 +113,49 @@ def test_svg_for_year_supports_monday_week_start(self) -> None: r'', ) + def test_svg_tooltip_includes_heart_rate_when_present(self) -> None: + entries = { + "2025-01-01": { + "count": 1, + "distance": 5000, + "moving_time": 1800, + "elevation_gain": 0, + "hr_weighted_sum": 261000.0, + "hr_time": 1800.0, + "heart_rate": 145.0, + "activity_ids": ["a"], + } + } + svg = generate_heatmaps._svg_for_year( + 2025, + entries, + {"distance": "mi", "elevation": "ft"}, + generate_heatmaps.DEFAULT_COLORS, + ) + + self.assertIn("Heart rate: 145 bpm", svg) + + def test_svg_tooltip_omits_heart_rate_when_absent(self) -> None: + entries = { + "2025-01-01": { + "count": 1, + "distance": 5000, + "moving_time": 1800, + "elevation_gain": 0, + "hr_weighted_sum": 0.0, + "hr_time": 0.0, + "activity_ids": ["a"], + } + } + svg = generate_heatmaps._svg_for_year( + 2025, + entries, + {"distance": "mi", "elevation": "ft"}, + generate_heatmaps.DEFAULT_COLORS, + ) + + self.assertNotIn("Heart rate:", svg) + def test_load_activities_filters_invalid_rows_and_parses_hour(self) -> None: rows = [ { diff --git a/tests/test_heart_rate_contract.py b/tests/test_heart_rate_contract.py new file mode 100644 index 0000000000..fdbb50349d --- /dev/null +++ b/tests/test_heart_rate_contract.py @@ -0,0 +1,66 @@ +import os +import re +import unittest + + +ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +APP_JS_PATH = os.path.join(ROOT_DIR, "site", "app.js") + + +def _extract_function_body(source: str, name: str) -> str: + """Extract a top-level JS function body by tracking brace depth. + + Needed because several HR touchpoints live in functions with nested + braces (forEach callbacks, if blocks); a non-greedy regex stops at the + first inner closing brace and misses the HR code we want to assert on. + """ + pattern = re.compile(rf"function\s+{re.escape(name)}\s*\([^)]*\)\s*\{{") + match = pattern.search(source) + if not match: + return "" + depth = 1 + i = match.end() + while i < len(source) and depth > 0: + ch = source[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + i += 1 + return source[match.end():i] + + +class HeartRateContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + with open(APP_JS_PATH, "r", encoding="utf-8") as handle: + cls.app_js = handle.read() + + def test_format_tooltip_metric_lines_renders_heart_rate_line(self) -> None: + body = _extract_function_body(self.app_js, "formatTooltipMetricLines") + self.assertTrue(body) + self.assertIn("Heart rate:", body) + self.assertIn("Math.round(heartRate)", body) + + def test_build_summary_accumulates_time_weighted_heart_rate(self) -> None: + body = _extract_function_body(self.app_js, "buildSummary") + self.assertTrue(body) + self.assertIn("totals.hr_weighted_sum += Number(", body) + self.assertIn("totals.hr_time += Number(", body) + self.assertIn("totals.heart_rate = totals.hr_time > 0", body) + + def test_combined_type_details_carries_heart_rate_per_type(self) -> None: + body = _extract_function_body(self.app_js, "buildCombinedTypeDetailsByDate") + self.assertTrue(body) + self.assertIn("heart_rate: Number(dayEntry?.heart_rate || 0)", body) + + def test_combine_year_aggregates_rolls_up_heart_rate(self) -> None: + body = _extract_function_body(self.app_js, "combineYearAggregates") + self.assertTrue(body) + self.assertIn("combined[dateStr].hr_weighted_sum += Number(", body) + self.assertIn("combined[dateStr].hr_time += Number(", body) + self.assertIn("heart_rate: entry.hr_time > 0 ? entry.hr_weighted_sum / entry.hr_time", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_heart_rate_pipeline.py b/tests/test_heart_rate_pipeline.py new file mode 100644 index 0000000000..ae2840095b --- /dev/null +++ b/tests/test_heart_rate_pipeline.py @@ -0,0 +1,126 @@ +import json +import os +import sys +import tempfile +import unittest +from unittest import mock + +import yaml + + +ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SCRIPTS_DIR = os.path.join(ROOT_DIR, "scripts") +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +import aggregate # noqa: E402 +import generate_heatmaps # noqa: E402 +import normalize # noqa: E402 + + +STRAVA_FIXTURE = { + "id": "100001", + "start_date_local": "2026-06-15T07:00:00+00:00", + "sport_type": "Run", + "type": "Run", + "name": "Morning Run", + "distance": 5000, + "moving_time": 1800, + "total_elevation_gain": 50, + "average_heartrate": 148.6, +} + +GARMIN_FIXTURE = { + "id": "200002", + "start_date_local": "2026-06-15T07:00:00", + "start_date": "2026-06-15T14:00:00", + "type": "running", + "sport_type": "running", + "name": "Garmin Run", + "distance": 5000, + "moving_time": 1800, + "total_elevation_gain": 50, + "average_heartrate": 148.6, + "provider": "garmin", +} + + +def _write_fixture(tmpdir: str, source: str, payload: dict) -> None: + raw_dir = os.path.join(tmpdir, "activities", "raw", source) + os.makedirs(raw_dir, exist_ok=True) + with open(os.path.join(raw_dir, f"{payload['id']}.json"), "w", encoding="utf-8") as handle: + json.dump(payload, handle) + + +def _write_config(tmpdir: str, source: str) -> None: + config = { + "source": source, + "sync": {}, + "activities": {"include_all_types": True}, + "units": {"distance": "mi", "elevation": "ft"}, + "heatmaps": {"week_start": "sunday"}, + } + with open(os.path.join(tmpdir, "config.yaml"), "w", encoding="utf-8") as handle: + yaml.safe_dump(config, handle) + + +def _run_pipeline_in_tmp(tmpdir: str) -> dict: + previous_cwd = os.getcwd() + os.chdir(tmpdir) + try: + normalize.ensure_dir("data") + items = normalize.normalize() + normalize.write_json(normalize.OUT_PATH, items) + output = aggregate.aggregate() + aggregate.ensure_dir("data") + aggregate.write_json(aggregate.OUT_PATH, output) + with mock.patch("generate_heatmaps._repo_slug_from_git", return_value=None): + generate_heatmaps.generate(write_svgs=False) + with open(os.path.join(tmpdir, "site", "data.json"), "r", encoding="utf-8") as handle: + return json.load(handle) + finally: + os.chdir(previous_cwd) + + +class HeartRatePipelineTests(unittest.TestCase): + def test_strava_fixture_propagates_heart_rate_to_site_data(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + _write_config(tmpdir, "strava") + _write_fixture(tmpdir, "strava", STRAVA_FIXTURE) + + data = _run_pipeline_in_tmp(tmpdir) + + entry = data["aggregates"]["2026"]["Run"]["2026-06-15"] + self.assertAlmostEqual(entry["heart_rate"], 148.6) + self.assertAlmostEqual(entry["hr_weighted_sum"], 148.6 * 1800) + self.assertEqual(entry["hr_time"], 1800.0) + + def test_garmin_fixture_propagates_heart_rate_to_site_data(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + _write_config(tmpdir, "garmin") + _write_fixture(tmpdir, "garmin", GARMIN_FIXTURE) + + data = _run_pipeline_in_tmp(tmpdir) + + entry = data["aggregates"]["2026"]["Run"]["2026-06-15"] + self.assertAlmostEqual(entry["heart_rate"], 148.6) + self.assertAlmostEqual(entry["hr_weighted_sum"], 148.6 * 1800) + self.assertEqual(entry["hr_time"], 1800.0) + + def test_no_heart_rate_fixture_omits_hr_fields(self) -> None: + no_hr_fixture = dict(STRAVA_FIXTURE) + no_hr_fixture["id"] = "100003" + del no_hr_fixture["average_heartrate"] + with tempfile.TemporaryDirectory() as tmpdir: + _write_config(tmpdir, "strava") + _write_fixture(tmpdir, "strava", no_hr_fixture) + + data = _run_pipeline_in_tmp(tmpdir) + + entry = data["aggregates"]["2026"]["Run"]["2026-06-15"] + self.assertNotIn("heart_rate", entry) + self.assertEqual(entry.get("hr_time", 0), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_normalize_and_aggregate.py b/tests/test_normalize_and_aggregate.py index 083f557516..8d628bc38c 100644 --- a/tests/test_normalize_and_aggregate.py +++ b/tests/test_normalize_and_aggregate.py @@ -32,6 +32,7 @@ def test_normalize_activity_extracts_fields_and_prefers_positive_duration(self) "duration": 95.0, "elapsed_time": 120.0, "totalElevationGain": "50", + "average_heartrate": 158.2, } type_aliases = {"Run": "Jog"} @@ -48,6 +49,62 @@ def test_normalize_activity_extracts_fields_and_prefers_positive_duration(self) self.assertEqual(normalized["moving_time"], 95.0) self.assertEqual(normalized["elevation_gain"], 50.0) self.assertEqual(normalized["name"], "Morning Session") + self.assertAlmostEqual(normalized["heart_rate"], 158.2) + + def test_normalize_activity_extracts_garmin_heart_rate_from_summary_dto(self) -> None: + activity = { + "id": 456, + "start_date_local": "2026-03-01T07:00:00+00:00", + "activityType": {"typeKey": "running"}, + "distance": 5000, + "duration": 1800, + "summaryDTO": {"averageHR": 145, "movingDuration": 1750}, + } + + normalized = normalize._normalize_activity(activity, type_aliases={}, source="garmin") + + self.assertAlmostEqual(normalized["heart_rate"], 145.0) + + def test_normalize_activity_extracts_garmin_heart_rate_from_top_level(self) -> None: + activity = { + "id": 789, + "start_date_local": "2026-03-02T07:00:00+00:00", + "activityType": {"typeKey": "running"}, + "distance": 3000, + "duration": 1200, + "averageHR": 152, + } + + normalized = normalize._normalize_activity(activity, type_aliases={}, source="garmin") + + self.assertAlmostEqual(normalized["heart_rate"], 152.0) + + def test_normalize_activity_omits_heart_rate_when_missing(self) -> None: + activity = { + "id": 321, + "start_date_local": "2026-03-03T07:00:00+00:00", + "sport_type": "Running", + "distance": 1000, + "moving_time": 300, + } + + normalized = normalize._normalize_activity(activity, type_aliases={}, source="strava") + + self.assertNotIn("heart_rate", normalized) + + def test_normalize_activity_rejects_implausible_heart_rate(self) -> None: + activity = { + "id": 654, + "start_date_local": "2026-03-04T07:00:00+00:00", + "sport_type": "Running", + "distance": 1000, + "moving_time": 300, + "average_heartrate": 300, + } + + normalized = normalize._normalize_activity(activity, type_aliases={}, source="strava") + + self.assertNotIn("heart_rate", normalized) def test_normalize_activity_returns_empty_when_missing_required_fields(self) -> None: self.assertEqual(normalize._normalize_activity({}, {}, "strava"), {}) @@ -108,6 +165,98 @@ def test_aggregate_groups_by_day_and_filters_types(self) -> None: self.assertEqual(run_entry["activity_ids"], ["a", "c"]) self.assertNotIn("Ride", output["years"]["2026"]) + def test_aggregate_computes_time_weighted_heart_rate(self) -> None: + config = {"activities": {"include_all_types": True}} + items = [ + { + "id": "a", + "date": "2026-04-01", + "year": 2026, + "type": "Run", + "distance": 5000, + "moving_time": 1800, + "elevation_gain": 0, + "heart_rate": 160.0, + }, + { + "id": "b", + "date": "2026-04-01", + "year": 2026, + "type": "Run", + "distance": 8000, + "moving_time": 3600, + "elevation_gain": 0, + "heart_rate": 140.0, + }, + ] + + with ( + mock.patch("aggregate.load_config", return_value=config), + mock.patch("aggregate.os.path.exists", return_value=True), + mock.patch("aggregate.read_json", return_value=items), + mock.patch("aggregate.utc_now", return_value=datetime(2026, 4, 14, tzinfo=timezone.utc)), + ): + output = aggregate.aggregate() + + entry = output["years"]["2026"]["Run"]["2026-04-01"] + self.assertEqual(entry["hr_time"], 5400.0) + self.assertEqual(entry["hr_weighted_sum"], 160.0 * 1800 + 140.0 * 3600) + self.assertAlmostEqual(entry["heart_rate"], (160.0 * 1800 + 140.0 * 3600) / 5400.0) + + def test_aggregate_skips_heart_rate_when_no_data(self) -> None: + config = {"activities": {"include_all_types": True}} + items = [ + { + "id": "a", + "date": "2026-04-02", + "year": 2026, + "type": "Run", + "distance": 5000, + "moving_time": 1800, + "elevation_gain": 0, + }, + ] + + with ( + mock.patch("aggregate.load_config", return_value=config), + mock.patch("aggregate.os.path.exists", return_value=True), + mock.patch("aggregate.read_json", return_value=items), + mock.patch("aggregate.utc_now", return_value=datetime(2026, 4, 14, tzinfo=timezone.utc)), + ): + output = aggregate.aggregate() + + entry = output["years"]["2026"]["Run"]["2026-04-02"] + self.assertEqual(entry["hr_time"], 0.0) + self.assertEqual(entry["hr_weighted_sum"], 0.0) + self.assertNotIn("heart_rate", entry) + + def test_aggregate_drops_heart_rate_when_moving_time_zero(self) -> None: + config = {"activities": {"include_all_types": True}} + items = [ + { + "id": "a", + "date": "2026-04-03", + "year": 2026, + "type": "StrengthTraining", + "distance": 0, + "moving_time": 0, + "elevation_gain": 0, + "heart_rate": 125.0, + }, + ] + + with ( + mock.patch("aggregate.load_config", return_value=config), + mock.patch("aggregate.os.path.exists", return_value=True), + mock.patch("aggregate.read_json", return_value=items), + mock.patch("aggregate.utc_now", return_value=datetime(2026, 4, 14, tzinfo=timezone.utc)), + ): + output = aggregate.aggregate() + + entry = output["years"]["2026"]["StrengthTraining"]["2026-04-03"] + self.assertEqual(entry["hr_time"], 0.0) + self.assertNotIn("heart_rate", entry) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_provider_fields.py b/tests/test_provider_fields.py index 844b849825..2f39bf215c 100644 --- a/tests/test_provider_fields.py +++ b/tests/test_provider_fields.py @@ -26,6 +26,15 @@ def test_pick_duration_seconds_prefers_positive(self) -> None: self.assertEqual(provider_fields.pick_duration_seconds("bad", -5, 0), -5) self.assertEqual(provider_fields.pick_duration_seconds(None, "", []), 0.0) + def test_pick_heart_rate_accepts_plausible_and_rejects_out_of_range(self) -> None: + self.assertEqual(provider_fields.pick_heart_rate(0.0, 148.6), 148.6) + self.assertEqual(provider_fields.pick_heart_rate(None, "", []), 0.0) + self.assertEqual(provider_fields.pick_heart_rate(1.0), 1.0) + self.assertEqual(provider_fields.pick_heart_rate(255.0), 255.0) + self.assertEqual(provider_fields.pick_heart_rate(256.0), 0.0) + self.assertEqual(provider_fields.pick_heart_rate(-5.0), 0.0) + self.assertEqual(provider_fields.pick_heart_rate("bad", 152.0), 152.0) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sync_garmin_normalize.py b/tests/test_sync_garmin_normalize.py new file mode 100644 index 0000000000..54ad3d1c8d --- /dev/null +++ b/tests/test_sync_garmin_normalize.py @@ -0,0 +1,80 @@ +import os +import sys +import types +import unittest + + +ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SCRIPTS_DIR = os.path.join(ROOT_DIR, "scripts") +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +yaml_stub = types.ModuleType("yaml") +yaml_stub.safe_load = lambda *_args, **_kwargs: {} +sys.modules.setdefault("yaml", yaml_stub) + +import sync_garmin # noqa: E402 + + +def _base_payload() -> dict: + return { + "activityId": "12345", + "startTimeLocal": "2026-03-14 07:00:00", + "startTimeGMT": "2026-03-14 14:00:00", + "activityType": {"typeKey": "running"}, + "distance": 5000, + "duration": 1800, + "movingDuration": 1750, + } + + +class SyncGarminNormalizeHeartRateTests(unittest.TestCase): + def test_normalize_activity_extracts_top_level_average_hr(self) -> None: + payload = dict(_base_payload(), averageHR=152) + + normalized = sync_garmin._normalize_activity(payload) + + self.assertAlmostEqual(normalized["average_heartrate"], 152.0) + + def test_normalize_activity_extracts_summary_dto_average_hr(self) -> None: + payload = dict(_base_payload()) + payload["summaryDTO"] = {"averageHR": 145} + + normalized = sync_garmin._normalize_activity(payload) + + self.assertAlmostEqual(normalized["average_heartrate"], 145.0) + + def test_normalize_activity_extracts_activity_summary_average_hr(self) -> None: + payload = dict(_base_payload()) + payload["activitySummary"] = {"averageHR": 138} + + normalized = sync_garmin._normalize_activity(payload) + + self.assertAlmostEqual(normalized["average_heartrate"], 138.0) + + def test_normalize_activity_omits_average_heartrate_when_missing(self) -> None: + payload = dict(_base_payload()) + + normalized = sync_garmin._normalize_activity(payload) + + self.assertNotIn("average_heartrate", normalized) + + def test_normalize_activity_rejects_implausible_heart_rate(self) -> None: + payload = dict(_base_payload(), averageHR=300) + + normalized = sync_garmin._normalize_activity(payload) + + self.assertNotIn("average_heartrate", normalized) + + def test_normalize_activity_prefers_first_plausible_candidate(self) -> None: + payload = dict(_base_payload()) + payload["averageHR"] = 0 + payload["summaryDTO"] = {"averageHR": 160} + + normalized = sync_garmin._normalize_activity(payload) + + self.assertAlmostEqual(normalized["average_heartrate"], 160.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py index 2f2de3ecaf..46212f687f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -54,6 +54,11 @@ def test_format_helpers_cover_us_and_metric_units(self) -> None: self.assertEqual(utils.format_elevation(100, "ft"), "328 ft") self.assertEqual(utils.format_elevation(100, "m"), "100 m") + def test_format_heart_rate_rounds_to_whole_bpm(self) -> None: + self.assertEqual(utils.format_heart_rate(0.0), "0 bpm") + self.assertEqual(utils.format_heart_rate(148.6), "149 bpm") + self.assertEqual(utils.format_heart_rate(162.0), "162 bpm") + if __name__ == "__main__": unittest.main()