diff --git a/README.md b/README.md index a9866356..fe3a46b9 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 @@ -50,13 +50,13 @@ Make your selection: ## API Coverage Statistics -- **Total API Methods**: 145+ 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) - **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) @@ -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 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", 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..b2cea382 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,89 @@ 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_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": []} + return { + "calendarItems": [ + { + "itemType": "workout", + "date": next_month_date, + "title": "Next month's run", + } + ] + } + + garmin.get_scheduled_workouts = fake_get_scheduled_workouts # type: ignore[method-assign] + + result = garmin.get_next_scheduled_workout() + + 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() + 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" # ---------------------------------------------------------------------------