Add training plans API support#285
Conversation
|
Warning
|
| Cohort / File(s) | Summary |
|---|---|
Documentation updateREADME.md |
Added “📅 Training Plans” to the main category list, updated category count (11 → 12) and added API coverage line noting 3 Training Plans methods (text-only changes). |
CLI / demodemo.py |
Added public get_training_plan_by_id_data(api); extended execute_api_call mapping with get_training_plans and get_training_plan_by_id; added main menu category "b" “📅 Training Plans” with options for listing plans and fetching by ID. |
Garmin API: Training Plansgarminconnect/__init__.py |
Added garmin_connect_training_plan_url attribute and three methods on Garmin: get_training_plans(), get_training_plan_by_id(plan_id), and get_adaptive_training_plan_by_id(plan_id) which validate IDs, build endpoints (/plans /plans/{id} /fbt-adaptive/{id}), log and delegate requests to connectapi. |
Sequence Diagram(s)
sequenceDiagram
autonumber
actor User
participant CLI as demo.py (menu)
participant Exec as execute_api_call
participant Garmin as Garmin
participant API as connectapi
User->>CLI: Select 📅 Training Plans
CLI->>Exec: Key "get_training_plans"
Exec->>Garmin: get_training_plans()
Garmin->>API: GET {garmin_connect_training_plan_url}/plans
API-->>Garmin: Plans list (JSON)
Garmin-->>Exec: Plans list
Exec-->>User: Display plans
rect rgba(200,230,255,0.3)
note over User,Garmin: Fetch training plan by ID (standard or adaptive)
User->>CLI: Choose "Get training plan by ID"
CLI->>Exec: Key "get_training_plan_by_id"
Exec->>CLI: Prompt for plan_id and (optional) category hint
CLI->>Exec: plan_id, category
alt Standard plan
Exec->>Garmin: get_training_plan_by_id(plan_id)
Garmin->>API: GET {garmin_connect_training_plan_url}/plans/{plan_id}
else Adaptive plan
Exec->>Garmin: get_adaptive_training_plan_by_id(plan_id)
Garmin->>API: GET {garmin_connect_training_plan_url}/fbt-adaptive/{plan_id}
end
API-->>Garmin: Plan details (JSON)
Garmin-->>Exec: Plan details
Exec-->>User: Display details
end
Estimated code review effort
🎯 3 (Moderate) | ⏱️ ~25 minutes
Poem
I hop through docs and code with cheer,
New calendars bloom, two choices appear.
IDs asked, adaptive or plain,
URLs stitched, responses gain.
A rabbit's nibble — menu and API near. 🐰📅
Pre-merge checks and finishing touches
✅ Passed checks (3 passed)
| Check name | Status | Explanation |
|---|---|---|
| Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. |
| Title Check | ✅ Passed | The title "Add training plans API support" is concise, specific, and accurately reflects the main change in the PR — introducing training plans endpoints and related demo/menu updates; it is clear and focused for a teammate scanning history. |
| Docstring Coverage | ✅ Passed | Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. |
✨ Finishing touches
- 📝 Generate Docstrings
🧪 Generate unit tests
- Create PR with unit tests
- Post copyable unit tests in a comment
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands and usage tips.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
README.md (1)
37-37: Update category count to reflect new menu section (now 12).You added a new main category; adjust the count.
-- **Categories**: 11 organized sections +- **Categories**: 12 organized sections
🧹 Nitpick comments (3)
README.md (1)
27-27: Fix title-case and trailing space in menu entry.Use “Training Plans” (title case) and drop the trailing space.
- [b] 📅 Training plans + [b] 📅 Training Plansgarminconnect/__init__.py (1)
269-270: Align attribute naming with existinggarmin_connect_*pattern.All other URL attributes use
garmin_connect_*. Rename for consistency.- self.garmin_training_plan_url = "/trainingplan-service/trainingplan" + self.garmin_connect_training_plan_url = "/trainingplan-service/trainingplan"And update call sites:
- url = f"{self.garmin_training_plan_url}/plans" + url = f"{self.garmin_connect_training_plan_url}/plans"- url = f"{self.garmin_training_plan_url}/plans/{plan_id}" + url = f"{self.garmin_connect_training_plan_url}/plans/{plan_id}"- url = f"{self.garmin_training_plan_url}/fbt-adaptive/{plan_id}" + url = f"{self.garmin_connect_training_plan_url}/fbt-adaptive/{plan_id}"demo.py (1)
1773-1800: Fix misleading docstring and make “by ID” actually accept an ID (with safe fallback).
- Docstring says adaptive not supported, but code does support it; update.
- Menu option says “by ID” but code always uses the last plan. Prompt for an ID and fallback to latest when omitted.
- Avoid KeyError if
trainingPlanListis absent.-def get_training_plan_by_id_data(api: Garmin) -> None: - """Get training plan by ID. adaptive plans are not supported. use get_adaptive_training_plan_by_id instead""" +def get_training_plan_by_id_data(api: Garmin) -> None: + """Get training plan details by ID (routes FBT_ADAPTIVE plans to the adaptive endpoint).""" try: - training_plans = api.get_training_plans()["trainingPlanList"] - if training_plans: - plan_id = training_plans[-1]["trainingPlanId"] - plan_name = training_plans[-1]["name"] - plan_category = training_plans[-1]["trainingPlanCategory"] - - if plan_category == "FBT_ADAPTIVE": - call_and_display( - api.get_adaptive_training_plan_by_id, - plan_id, - method_name="get_adaptive_training_plan_by_id", - api_call_desc=f"api.get_adaptive_training_plan_by_id({plan_id}) - {plan_name}", - ) - else: - call_and_display( - api.get_training_plan_by_id, - plan_id, - method_name="get_training_plan_by_id", - api_call_desc=f"api.get_training_plan_by_id({plan_id}) - {plan_name}", - ) - else: - print("ℹ️ No training plans found") + resp = api.get_training_plans() or {} + training_plans = resp.get("trainingPlanList") or [] + if not training_plans: + print("ℹ️ No training plans found") + return + + user_input = input("Enter training plan ID (press Enter for most recent): ").strip() + selected = None + if user_input: + try: + wanted_id = int(user_input) + selected = next( + (p for p in training_plans if int(p.get("trainingPlanId", 0)) == wanted_id), + None, + ) + if not selected: + print(f"ℹ️ Plan ID {wanted_id} not found in your plans; attempting fetch anyway") + plan_id = wanted_id + plan_name = f"Plan {wanted_id}" + plan_category = None + else: + plan_id = int(selected["trainingPlanId"]) + plan_name = selected.get("name", str(plan_id)) + plan_category = selected.get("trainingPlanCategory") + except ValueError: + print("❌ Invalid plan ID") + return + else: + selected = training_plans[-1] + plan_id = int(selected["trainingPlanId"]) + plan_name = selected.get("name", str(plan_id)) + plan_category = selected.get("trainingPlanCategory") + + if plan_category == "FBT_ADAPTIVE": + call_and_display( + api.get_adaptive_training_plan_by_id, + plan_id, + method_name="get_adaptive_training_plan_by_id", + api_call_desc=f"api.get_adaptive_training_plan_by_id({plan_id}) - {plan_name}", + ) + else: + call_and_display( + api.get_training_plan_by_id, + plan_id, + method_name="get_training_plan_by_id", + api_call_desc=f"api.get_training_plan_by_id({plan_id}) - {plan_name}", + ) except Exception as e: print(f"❌ Error getting plan by ID: {e}")
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
README.md(2 hunks)demo.py(3 hunks)garminconnect/__init__.py(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
demo.py (1)
garminconnect/__init__.py (3)
get_training_plans(2168-2173)get_adaptive_training_plan_by_id(2184-2191)get_training_plan_by_id(2175-2182)
🔇 Additional comments (5)
garminconnect/__init__.py (3)
2168-2174: LGTM: list endpoint wiring and logging are correct.Endpoint path and error flow via
connectapilook consistent with the rest of the class.If some accounts/regions return a different envelope than
{"trainingPlanList": [...]}, the demo may KeyError. Consider confirming envelope shape across profiles.
2175-2183: LGTM: ID validation and fetch by ID.Casts to int and validates positivity; URL path looks correct.
2184-2192: LGTM: adaptive plan by ID.Consistent validation and routing to adaptive endpoint.
demo.py (2)
418-424: LGTM: New Training Plans menu category.Menu wiring is clear and matches the new API methods.
3163-3167: LGTM: API mapping for new endpoints.Entries correctly route to the newly added functions.
Based on CodeRabbit feedback Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (1)
demo.py (1)
1773-1828: Harden error handling and truly pick “most recent”; normalize adaptive check
- Wrap the initial fetch with safe_api_call to reuse uniform HTTP handling.
- When defaulting to “most recent,” sort by an available timestamp instead of taking the last element.
- Normalize the category comparison to be case-insensitive.
Suggested patch:
def get_training_plan_by_id_data(api: Garmin) -> None: """Get training plan details by ID (routes FBT_ADAPTIVE plans to the adaptive endpoint).""" - resp = api.get_training_plans() or {} - training_plans = resp.get("trainingPlanList") or [] + ok, resp, _ = safe_api_call(api.get_training_plans, method_name="get_training_plans") + if not ok or not resp: + return + training_plans = (resp or {}).get("trainingPlanList") or [] if not training_plans: print("ℹ️ No training plans found") return @@ - else: - selected = training_plans[-1] - plan_id = int(selected["trainingPlanId"]) - plan_name = selected.get("name", str(plan_id)) - plan_category = selected.get("trainingPlanCategory") + else: + # Choose the most recently updated/created plan when no ID is provided + candidates = ["lastUpdatedDate", "updatedDate", "createDate", "startDate"] + def _to_dt(p: dict) -> datetime.datetime: + for k in candidates: + v = p.get(k) + if isinstance(v, str): + with suppress(Exception): + return datetime.datetime.fromisoformat(v.replace("Z", "+00:00")) + return datetime.datetime.min + selected = max(training_plans, key=_to_dt, default=training_plans[-1]) + plan_id = int(selected["trainingPlanId"]) + plan_name = selected.get("name", str(plan_id)) + plan_category = selected.get("trainingPlanCategory") - if plan_category == "FBT_ADAPTIVE": + category_norm = (str(plan_category).upper() if plan_category is not None else "") + if category_norm == "FBT_ADAPTIVE": call_and_display( api.get_adaptive_training_plan_by_id, plan_id, method_name="get_adaptive_training_plan_by_id", api_call_desc=f"api.get_adaptive_training_plan_by_id({plan_id}) - {plan_name}", )Please confirm the training plan list objects expose one of these timestamp fields: lastUpdatedDate, updatedDate, createDate, or startDate. If they differ, adjust the candidates list accordingly.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
README.md(3 hunks)demo.py(3 hunks)garminconnect/__init__.py(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- README.md
- garminconnect/init.py
🧰 Additional context used
🧬 Code graph analysis (1)
demo.py (1)
garminconnect/__init__.py (4)
Garmin(94-2191)get_training_plans(2168-2173)get_adaptive_training_plan_by_id(2184-2191)get_training_plan_by_id(2175-2182)
🔇 Additional comments (2)
demo.py (2)
418-424: Nice addition: Training Plans category in the menuCategory wiring and option keys look consistent with the rest of the menu system.
3190-3195: API method wiring — LGTMBoth new keys are correctly mapped; get_training_plan_by_id uses the helper that routes adaptive plans.
|
Hi Nick - This is a great start - Are you considering adding pushing training plans support as well? Note: I have some training plans created in my account, but when I asked to list the training plans, I got an empty array. |
That was my plan, but to keep the merge request small i started with adaptive training plan which is what is use. What kind of training plan have you setup in connect? is it a Garmin Coach plan or a regular training plan for eihter running or cycling |
|
I have created a Regular Plan through garmin connect - Wanted to know if I could programatically do it somehow like how (https://lifttrackapp.com/) does with Strength workouts |
|
@nickknissen Thanks, nice stuff! |

I have added support for fetching training plan data.
There are 3 endpoints added:
get_training_plans()- Retrieve all available training plansget_training_plan_by_id()- Get details for a specific training planget_adaptive_training_plan_by_id()- Get details for adaptive training plansThere are more training plan endpoints, like Strength Training Plans, Phased Training Plans, but this is a start.
Summary by CodeRabbit
New Features
Documentation