From c421ae147654738e4f1082289fb664faf78d14af Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Wed, 9 Sep 2026 13:43:38 +0200 Subject: [PATCH 1/2] demo: decode running typeId in get_personal_record's interactive display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_personal_record() previously just dumped the raw API response. This decodes the running-activity typeId mapping (1km/mile/5km/10km/half/ marathon/longest-run, per the schema documented in PR #423's docstring) into a readable label, and lets you pick a record to look up its source activity via the existing get_activity() — needed for longest-run duration, since the personal-record entry itself only has distance. Only the running mapping is confirmed; other activity types' typeId values are shown as-is. Index parsing rejects negative/out-of-range values explicitly rather than relying on Python's negative-index list-indexing. --- demo.py | 88 +++++++++++++++++++++++++++-- tests/test_demo_personal_records.py | 75 ++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 tests/test_demo_personal_records.py diff --git a/demo.py b/demo.py index ad0a3d34..6ff6fac1 100755 --- a/demo.py +++ b/demo.py @@ -469,7 +469,10 @@ def __init__(self): "7": { "name": "🏆 Goals & Achievements", "options": { - "1": {"desc": "Get personal records", "key": "get_personal_records"}, + "1": { + "desc": "Get personal records (decodes running typeId, interactive)", + "key": "get_personal_records", + }, "2": {"desc": "Get earned badges", "key": "get_earned_badges"}, "3": {"desc": "Get adhoc challenges", "key": "get_adhoc_challenges"}, "4": { @@ -3607,6 +3610,83 @@ def create_gear_data(api: Garmin) -> None: print(f"❌ Error creating gear: {e}") +def get_personal_records_data(api: Garmin) -> None: + """Get personal records, decoding running typeId and optionally looking + up the source activity's details (needed for longest-run duration, + which isn't included in the personal-record entry itself). + + Only the running typeId mapping (1-7) is confirmed; other activity + types' typeId values are shown as-is, undecoded. + """ + running_type_labels = { + 1: "1 km", + 2: "1 mile", + 3: "5 km", + 4: "10 km", + 5: "Half marathon", + 6: "Marathon", + 7: "Longest run", + } + try: + success, records = call_and_display( + api.get_personal_record, + method_name="get_personal_record", + api_call_desc="api.get_personal_record()", + ) + if not success or not records: + return + + entries = records if isinstance(records, list) else [records] + if not entries: + print("â„šī¸ No personal records found") + return + + print("\nPersonal records:") + for i, entry in enumerate(entries): + type_id = entry.get("typeId") + activity_type = entry.get("activityType") + label = ( + running_type_labels.get(type_id) if activity_type == "running" else None + ) + desc = f"typeId={type_id}" + if label: + desc += f" ({label})" + print( + f"{i}: {desc} activityType={activity_type!r} value={entry.get('value')}" + ) + + choice = input( + "\nEnter index to look up the source activity's details (blank to skip): " + ).strip() + if not choice: + return + + try: + idx = int(choice) + if not (0 <= idx < len(entries)): + print("❌ Invalid index") + return + except ValueError: + print("❌ Invalid index") + return + + activity_id = entries[idx].get("activityId") or entries[idx].get( + "activityIdInt" + ) + if not activity_id: + print("â„šī¸ This record entry has no activityId to look up") + return + + call_and_display( + api.get_activity, + str(activity_id), + method_name="get_activity", + api_call_desc=f"api.get_activity('{activity_id}')", + ) + except Exception as e: + print(f"❌ Error getting personal records: {e}") + + def set_activity_name_data(api: Garmin) -> None: """Set activity name.""" try: @@ -4580,11 +4660,7 @@ def execute_api_call(api: Garmin, key: str) -> None: "delete_weigh_ins": lambda: delete_weigh_ins_data(api), "delete_weigh_in": lambda: delete_weigh_in_data(api), # Goals & Achievements - "get_personal_records": lambda: call_and_display( - api.get_personal_record, - method_name="get_personal_record", - api_call_desc="api.get_personal_record()", - ), + "get_personal_records": lambda: get_personal_records_data(api), "get_earned_badges": lambda: call_and_display( api.get_earned_badges, method_name="get_earned_badges", diff --git a/tests/test_demo_personal_records.py b/tests/test_demo_personal_records.py new file mode 100644 index 00000000..b8749457 --- /dev/null +++ b/tests/test_demo_personal_records.py @@ -0,0 +1,75 @@ +"""Regression tests for demo.py's personal-records interactive helper.""" + +import sys +from types import SimpleNamespace + +sys.modules.setdefault("readchar", SimpleNamespace(readkey=lambda: "q")) # type: ignore[arg-type] + +import demo # noqa: E402 + + +class _FakeAPI: + def __init__(self): + self.records = [ + { + "typeId": 4, + "activityType": "running", + "value": 2400.5, + "activityId": 111, + }, + {"typeId": 1, "activityType": "cycling", "value": 999}, + ] + self.get_activity_calls: list[str] = [] + + def get_personal_record(self): + return self.records + + def get_activity(self, activity_id): + self.get_activity_calls.append(activity_id) + return {"activityId": activity_id} + + +def _typed_inputs(*values): + it = iter(values) + return lambda prompt="": next(it) + + +def test_decodes_running_typeid_label(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("")) + + demo.get_personal_records_data(api) + + out = capsys.readouterr().out + assert "typeId=4 (10 km)" in out + # Non-running entries are shown undecoded, not mislabeled. + assert "typeId=1 activityType='cycling'" in out + + +def test_looks_up_source_activity_by_index(monkeypatch): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("0")) + + demo.get_personal_records_data(api) + + assert api.get_activity_calls == ["111"] + + +def test_negative_index_is_rejected(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("-1")) + + demo.get_personal_records_data(api) + + assert api.get_activity_calls == [] + assert "Invalid index" in capsys.readouterr().out + + +def test_entry_without_activity_id_is_reported(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("1")) + + demo.get_personal_records_data(api) + + assert api.get_activity_calls == [] + assert "no activityId" in capsys.readouterr().out From 549fd1d169dd92b058e61ca78bd1bd75cbe91959 Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Wed, 9 Sep 2026 16:34:35 +0200 Subject: [PATCH 2/2] demo: format personal-record values instead of dumping raw numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed the raw "value" field's meaning against a real account's Garmin Connect "Personal Records" page and format it accordingly instead of printing an unlabeled float: - Running typeIds 1-6 (fixed-distance categories) are best times — value in seconds, now shown as m:ss / h:mm:ss. - Running typeId 7 (longest run) is a distance — value in meters, now shown in km. - With activityType null, typeIds 12-14 are step counts (day/week/ month) and 15-16 are goal-streak day counts — both newly confirmed and decoded (previously shown as "unconfirmed"). Anything outside those two confirmed typeId/activityType combinations still prints in full (typeId + raw value), just without a label, rather than being hidden or guessed at. --- demo.py | 110 ++++++++++++++++++++++------ tests/test_demo_personal_records.py | 63 ++++++++++++++-- 2 files changed, 145 insertions(+), 28 deletions(-) diff --git a/demo.py b/demo.py index 6ff6fac1..a1b0bca4 100755 --- a/demo.py +++ b/demo.py @@ -3610,22 +3610,76 @@ def create_gear_data(api: Garmin) -> None: print(f"❌ Error creating gear: {e}") -def get_personal_records_data(api: Garmin) -> None: - """Get personal records, decoding running typeId and optionally looking - up the source activity's details (needed for longest-run duration, - which isn't included in the personal-record entry itself). +def _format_record_duration(seconds: float) -> str: + """Format a personal-record time value (seconds) as h:mm:ss or m:ss.""" + total = round(seconds) + hours, remainder = divmod(total, 3600) + minutes, secs = divmod(remainder, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes}:{secs:02d}" + + +def _format_record_distance(meters: float) -> str: + """Format a personal-record distance value (meters) as km.""" + return f"{meters / 1000:.2f} km" + + +def _format_record_count(value: float) -> str: + """Format a personal-record count value (e.g. steps) with thousands separators.""" + return f"{round(value):,}" + - Only the running typeId mapping (1-7) is confirmed; other activity - types' typeId values are shown as-is, undecoded. +def _format_record_days(value: float) -> str: + """Format a personal-record day-count value (e.g. a goal streak).""" + days = round(value) + return f"{days} day" if days == 1 else f"{days} days" + + +_RECORD_FORMATTERS = { + "time": _format_record_duration, + "distance": _format_record_distance, + "count": _format_record_count, + "days": _format_record_days, +} + + +def get_personal_records_data(api: Garmin) -> None: + """Get personal records, decoding typeId into a human-readable label + and value, and optionally looking up the source activity's details + (needed for longest-run duration, which isn't included in the + personal-record entry itself). + + Two typeId ranges are confirmed against a real account's Garmin + Connect "Personal Records" page: + - activityType == "running": typeIds 1-6 are best-time records for + a fixed distance (value is a duration in seconds), typeId 7 is a + distance record (value is meters). + - activityType is None: typeIds 12-14 are step counts (day/week/ + month), 15-16 are goal-streak day counts. + Anything else is shown as an unconfirmed raw value — check the Garmin + Connect "Personal Records" page for what it actually is rather than + trusting a guess here. """ - running_type_labels = { - 1: "1 km", - 2: "1 mile", - 3: "5 km", - 4: "10 km", - 5: "Half marathon", - 6: "Marathon", - 7: "Longest run", + # (label, formatter key) — see _RECORD_FORMATTERS. Confirmed against a + # real account; see docstring. + running_type_info = { + 1: ("1 km", "time"), + 2: ("1 mile", "time"), + 3: ("5 km", "time"), + 4: ("10 km", "time"), + 5: ("Half marathon", "time"), + 6: ("Marathon", "time"), + 7: ("Longest run", "distance"), + } + # activityType is None for these — steps/streak records, not tied to a + # specific sport. + other_type_info = { + 12: ("Most steps in a day", "count"), + 13: ("Most steps in a week", "count"), + 14: ("Most steps in a month", "count"), + 15: ("Longest goal streak", "days"), + 16: ("Current goal streak", "days"), } try: success, records = call_and_display( @@ -3645,15 +3699,25 @@ def get_personal_records_data(api: Garmin) -> None: for i, entry in enumerate(entries): type_id = entry.get("typeId") activity_type = entry.get("activityType") - label = ( - running_type_labels.get(type_id) if activity_type == "running" else None - ) - desc = f"typeId={type_id}" - if label: - desc += f" ({label})" - print( - f"{i}: {desc} activityType={activity_type!r} value={entry.get('value')}" - ) + value = entry.get("value") + if activity_type == "running": + info = running_type_info.get(type_id) + elif activity_type is None: + info = other_type_info.get(type_id) + else: + info = None + + if info is not None and isinstance(value, int | float): + label, kind = info + formatted = _RECORD_FORMATTERS[kind](value) + print( + f"{i}: {label} — {formatted} (typeId={type_id}, raw value={value})" + ) + else: + print( + f"{i}: Unconfirmed record type " + f"(typeId={type_id}, activityType={activity_type!r}, raw value={value})" + ) choice = input( "\nEnter index to look up the source activity's details (blank to skip): " diff --git a/tests/test_demo_personal_records.py b/tests/test_demo_personal_records.py index b8749457..86d9783d 100644 --- a/tests/test_demo_personal_records.py +++ b/tests/test_demo_personal_records.py @@ -14,10 +14,14 @@ def __init__(self): { "typeId": 4, "activityType": "running", - "value": 2400.5, + "value": 2400, "activityId": 111, }, + {"typeId": 7, "activityType": "running", "value": 5000}, + {"typeId": 12, "activityType": None, "value": 26246.0}, + {"typeId": 15, "activityType": None, "value": 1.0}, {"typeId": 1, "activityType": "cycling", "value": 999}, + {"typeId": 99, "activityType": None, "value": 42.0}, ] self.get_activity_calls: list[str] = [] @@ -34,16 +38,65 @@ def _typed_inputs(*values): return lambda prompt="": next(it) -def test_decodes_running_typeid_label(monkeypatch, capsys): +def test_decodes_running_time_record(monkeypatch, capsys): api = _FakeAPI() monkeypatch.setattr("builtins.input", _typed_inputs("")) demo.get_personal_records_data(api) out = capsys.readouterr().out - assert "typeId=4 (10 km)" in out - # Non-running entries are shown undecoded, not mislabeled. - assert "typeId=1 activityType='cycling'" in out + assert "10 km — 40:00" in out + assert "typeId=4, raw value=2400" in out + + +def test_decodes_running_distance_record(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("")) + + demo.get_personal_records_data(api) + + out = capsys.readouterr().out + assert "Longest run — 5.00 km" in out + + +def test_decodes_step_count_record(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("")) + + demo.get_personal_records_data(api) + + out = capsys.readouterr().out + assert "Most steps in a day — 26,246" in out + + +def test_decodes_goal_streak_singular_day(monkeypatch, capsys): + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("")) + + demo.get_personal_records_data(api) + + out = capsys.readouterr().out + assert "Longest goal streak — 1 day " in out + assert "1 days" not in out + + +def test_unconfirmed_types_are_shown_not_hidden(monkeypatch, capsys): + """Records outside both confirmed typeId tables must still be printed + in full (typeId + raw value), not dropped or given a made-up label. + """ + api = _FakeAPI() + monkeypatch.setattr("builtins.input", _typed_inputs("")) + + demo.get_personal_records_data(api) + + out = capsys.readouterr().out + assert ( + "Unconfirmed record type (typeId=1, activityType='cycling', raw value=999)" + in out + ) + assert ( + "Unconfirmed record type (typeId=99, activityType=None, raw value=42.0)" in out + ) def test_looks_up_source_activity_by_index(monkeypatch):