Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions garminconnect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
84 changes: 84 additions & 0 deletions tests/test_garmin_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]
}

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"
# ---------------------------------------------------------------------------
Expand Down
Loading