From 2793b07ec4dc250e57bd18478da0da0712f81e7b Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Sat, 12 Sep 2026 12:41:57 +0000 Subject: [PATCH 1/4] feat: add get_next_scheduled_workout Returns the earliest upcoming calendar-service workout item (today or later), checking the current and next calendar month like get_scheduled_workouts already requires callers to do manually. Note: this only sees whatever Garmin's calendar-service endpoint has already committed to the visible calendar. For adaptive/Coach plans the Garmin Connect app can show upcoming sessions this endpoint doesn't return at all -- that fuller view needs session-cookie authentication this client doesn't use, so expect this to sometimes return {} even with an active plan and visible sessions in the app. --- garminconnect/__init__.py | 36 +++++++++++++++++++++++++ tests/test_garmin_unit.py | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/garminconnect/__init__.py b/garminconnect/__init__.py index af63f804..1d4c13bb 100644 --- a/garminconnect/__init__.py +++ b/garminconnect/__init__.py @@ -3608,6 +3608,42 @@ def get_scheduled_workout_by_id( logger.debug("Requesting scheduled workout by id %d", scheduled_workout_id) return self.connectapi(url) + def get_next_scheduled_workout(self) -> dict[str, Any]: + """Return the earliest upcoming scheduled workout (today or later). + + Checks the current and next calendar month via + ``get_scheduled_workouts`` and returns the first calendar item + with ``itemType == "workout"`` whose date is today or later, or + an empty dict if none is scheduled. + + Note: Garmin's calendar-service only returns workouts it has + already committed to the visible calendar. For adaptive/Coach + plans, the Garmin Connect app can show upcoming sessions this + won't -- that fuller view lives behind a different, + session-cookie-authenticated API this client doesn't use. + """ + today = date.today() + next_month = today.month + 1 if today.month < 12 else 1 + next_month_year = today.year if today.month < 12 else today.year + 1 + + this_month = self.get_scheduled_workouts(today.year, today.month) or {} + next_month_data = self.get_scheduled_workouts(next_month_year, next_month) or {} + calendar_items = (this_month.get("calendarItems") or []) + ( + next_month_data.get("calendarItems") or [] + ) + + today_str = today.isoformat() + workouts = sorted( + ( + item + for item in calendar_items + if item.get("itemType") == "workout" + and (item.get("date") or "") >= today_str + ), + key=lambda item: item.get("date") or "", + ) + return workouts[0] if workouts else {} + def schedule_workout(self, workout_id: int | str, date_str: str) -> dict[str, Any]: """Schedule a workout on a specific date in the Garmin calendar. diff --git a/tests/test_garmin_unit.py b/tests/test_garmin_unit.py index 02f6aca0..c79efe20 100644 --- a/tests/test_garmin_unit.py +++ b/tests/test_garmin_unit.py @@ -23,6 +23,7 @@ import threading import time from contextlib import ExitStack, contextmanager +from datetime import date, timedelta from pathlib import Path from typing import Any from unittest.mock import call, patch @@ -2466,6 +2467,61 @@ def test_delete_blood_pressure_validates_date(self, garmin: garminconnect.Garmin garmin.delete_blood_pressure("1", "not-a-date") +# --------------------------------------------------------------------------- +# get_next_scheduled_workout: earliest upcoming workout across this + next month +# --------------------------------------------------------------------------- + + +class TestGetNextScheduledWorkout: + def test_returns_earliest_upcoming_workout_across_both_months( + self, garmin: garminconnect.Garmin + ): + today = date.today() + today_str = today.isoformat() + later_str = (today + timedelta(days=10)).isoformat() + + def fake_get_scheduled_workouts(year, month): + if (year, month) == (today.year, today.month): + return { + "calendarItems": [ + {"itemType": "weight", "date": today_str}, + {"itemType": "workout", "date": later_str, "title": "Long run"}, + ] + } + return { + "calendarItems": [ + { + "itemType": "workout", + "date": later_str, + "title": "Should not win", + } + ] + } + + garmin.get_scheduled_workouts = fake_get_scheduled_workouts # type: ignore[method-assign] + + result = garmin.get_next_scheduled_workout() + assert result["title"] == "Long run" + assert result["date"] == later_str + + def test_ignores_past_workouts(self, garmin: garminconnect.Garmin): + today = date.today() + past_str = (today - timedelta(days=1)).isoformat() + + garmin.get_scheduled_workouts = lambda year, month: { # type: ignore[method-assign] + "calendarItems": [{"itemType": "workout", "date": past_str}] + } + + assert garmin.get_next_scheduled_workout() == {} + + def test_returns_empty_dict_when_nothing_scheduled( + self, garmin: garminconnect.Garmin + ): + garmin.get_scheduled_workouts = lambda year, month: {"calendarItems": []} # type: ignore[method-assign] + + assert garmin.get_next_scheduled_workout() == {} + + # --------------------------------------------------------------------------- # create_gear: matches the payload captured from Garmin Connect's "Add Gear" # --------------------------------------------------------------------------- From 75ba7055faea57c0f68d24db37ba264577f8c10e Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Sat, 12 Sep 2026 12:49:04 +0000 Subject: [PATCH 2/4] demo/docs: wire up get_next_scheduled_workout Menu entry '5B' under Activities & Workouts, dispatch to a new get_next_scheduled_workout_data(). Bumps README's method-count snapshot. --- README.md | 4 ++-- demo.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a9866356..5f2e93e4 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,13 @@ Make your selection: ## API Coverage Statistics -- **Total API Methods**: 145+ unique endpoints (snapshot) +- **Total API Methods**: 146+ unique endpoints (snapshot) - **Categories**: 14 organized sections - **User & Profile**: 4 methods (basic user info, settings) - **Daily Health & Activity**: 10 methods (today's health data plus daily calories, resting HR and sleep ranges) - **Advanced Health Metrics**: 16 methods (fitness metrics, HRV, VO2, FTP range, training readiness, training zones, running tolerance) - **Historical Data & Trends**: 9 methods (date range queries, weekly aggregates) -- **Activities & Workouts**: 36 methods (comprehensive activity, workout management, typed workout uploads including strength, in-place edit, scheduling, push to device, import, activity type/subtype filtering) +- **Activities & Workouts**: 37 methods (comprehensive activity, workout management, typed workout uploads including strength, in-place edit, scheduling, push to device, import, activity type/subtype filtering, earliest-upcoming-workout lookup) - **Body Composition & Weight**: 7 methods (weight tracking, body composition) - **Goals & Achievements**: 15 methods (challenges, badges, goals) - **Device & Technical**: 7 methods (device info, settings) diff --git a/demo.py b/demo.py index a1b0bca4..ca947072 100755 --- a/demo.py +++ b/demo.py @@ -437,6 +437,10 @@ def __init__(self): "desc": "Get activities filtered by type/subtype (interactive)", "key": "get_activities_filtered", }, + "B": { + "desc": "Get the earliest upcoming scheduled workout (today or later)", + "key": "get_next_scheduled_workout", + }, }, }, "6": { @@ -2773,6 +2777,18 @@ def get_scheduled_workouts(api: Garmin) -> None: print(f"❌ Error getting scheduled workouts by year and month: {e}") +def get_next_scheduled_workout_data(api: Garmin) -> None: + """Get the earliest upcoming scheduled workout (today or later).""" + try: + call_and_display( + api.get_next_scheduled_workout, + method_name="get_next_scheduled_workout", + api_call_desc="api.get_next_scheduled_workout()", + ) + except Exception as e: + print(f"❌ Error getting next scheduled workout: {e}") + + def get_scheduled_workout_by_id_data(api: Garmin) -> None: """Get scheduled workout by ID.""" try: @@ -4612,6 +4628,7 @@ def execute_api_call(api: Garmin, key: str) -> None: api_call_desc=f"api.get_activities({config.start}, {config.default_limit})", ), "get_activities_filtered": lambda: get_activities_filtered_data(api), + "get_next_scheduled_workout": lambda: get_next_scheduled_workout_data(api), "get_last_activity": lambda: call_and_display( api.get_last_activity, method_name="get_last_activity", From a0a224aff62f636651893d81526deb8d90559e75 Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Sat, 12 Sep 2026 12:49:29 +0000 Subject: [PATCH 3/4] docs: fix remaining 145+ -> 146+ method count references --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5f2e93e4..1162d812 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The Garmin Connect API library comes with two examples: - **`example.py`** - Simple getting-started example showing authentication, token storage, and basic API calls -- **`demo.py`** - Comprehensive demo providing access to **145+ API methods** organized into **14 categories** for easy navigation +- **`demo.py`** - Comprehensive demo providing access to **146+ API methods** organized into **14 categories** for easy navigation ```bash $ ./demo.py @@ -114,7 +114,7 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate pip install -e ".[example]" python3 ./example.py # simple getting-started example -python3 ./demo.py # comprehensive demo (145+ API methods) +python3 ./demo.py # comprehensive demo (146+ API methods) ``` ## 🛠️ Development @@ -460,7 +460,7 @@ user_stats = client.get_golf_user_stats() ### Additional Resources - **Simple Example**: [example.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/example.py) - Getting started guide -- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 145+ API methods +- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 146+ API methods - **API Documentation**: Comprehensive method documentation in source code - **Test Cases**: Real-world usage examples in `tests/` directory From 1b5e3eb25a7e29ddeb521f70725a1b945517916d Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Sat, 12 Sep 2026 15:04:28 +0200 Subject: [PATCH 4/4] fix: address CodeRabbit findings on get_next_scheduled_workout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: describe the 146+ count as methods, not unique endpoints — get_next_scheduled_workout() composes two calls to the existing get_scheduled_workouts endpoint, it doesn't add a new one. - Test: the next-month fallback test gave both months an item on the same date, so a stable sort would pick the current-month item first regardless of whether next month was ever queried — a buggy implementation that never checked next month would still pass it. Replaced with a case where the current month has no eligible workout and asserts both (year, month) pairs are actually requested. --- README.md | 2 +- tests/test_garmin_unit.py | 50 ++++++++++++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1162d812..fe3a46b9 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Make your selection: ## API Coverage Statistics -- **Total API Methods**: 146+ unique endpoints (snapshot) +- **Total API Methods**: 146+ methods (snapshot) - **Categories**: 14 organized sections - **User & Profile**: 4 methods (basic user info, settings) - **Daily Health & Activity**: 10 methods (today's health data plus daily calories, resting HR and sleep ranges) diff --git a/tests/test_garmin_unit.py b/tests/test_garmin_unit.py index c79efe20..b2cea382 100644 --- a/tests/test_garmin_unit.py +++ b/tests/test_garmin_unit.py @@ -2473,27 +2473,50 @@ def test_delete_blood_pressure_validates_date(self, garmin: garminconnect.Garmin class TestGetNextScheduledWorkout: - def test_returns_earliest_upcoming_workout_across_both_months( + def test_returns_earliest_upcoming_workout_in_current_month( self, garmin: garminconnect.Garmin ): today = date.today() today_str = today.isoformat() later_str = (today + timedelta(days=10)).isoformat() + garmin.get_scheduled_workouts = lambda year, month: { # type: ignore[method-assign] + "calendarItems": [ + {"itemType": "weight", "date": today_str}, + {"itemType": "workout", "date": later_str, "title": "Long run"}, + ] + } + + result = garmin.get_next_scheduled_workout() + assert result["title"] == "Long run" + assert result["date"] == later_str + + def test_falls_back_to_next_month_when_current_month_has_none( + self, garmin: garminconnect.Garmin + ): + """Current month has no eligible workout, so the result must come + from next month, not just whichever month happens first in the + merged list (issue: both months previously used the same date, + so a stable sort could pick the wrong month's item for the wrong + reason and the test wouldn't catch it). + """ + today = date.today() + next_month = today.month + 1 if today.month < 12 else 1 + next_month_year = today.year if today.month < 12 else today.year + 1 + next_month_date = date(next_month_year, next_month, 15).isoformat() + + requested_months: list[tuple[int, int]] = [] + def fake_get_scheduled_workouts(year, month): + requested_months.append((year, month)) if (year, month) == (today.year, today.month): - return { - "calendarItems": [ - {"itemType": "weight", "date": today_str}, - {"itemType": "workout", "date": later_str, "title": "Long run"}, - ] - } + return {"calendarItems": []} return { "calendarItems": [ { "itemType": "workout", - "date": later_str, - "title": "Should not win", + "date": next_month_date, + "title": "Next month's run", } ] } @@ -2501,8 +2524,13 @@ def fake_get_scheduled_workouts(year, month): garmin.get_scheduled_workouts = fake_get_scheduled_workouts # type: ignore[method-assign] result = garmin.get_next_scheduled_workout() - assert result["title"] == "Long run" - assert result["date"] == later_str + + assert requested_months == [ + (today.year, today.month), + (next_month_year, next_month), + ] + assert result["title"] == "Next month's run" + assert result["date"] == next_month_date def test_ignores_past_workouts(self, garmin: garminconnect.Garmin): today = date.today()