Skip to content

Conversation

nickknissen
Copy link

@nickknissen nickknissen commented Sep 23, 2025

I have added support for fetching training plan data.

There are 3 endpoints added:

  • get_training_plans() - Retrieve all available training plans
  • get_training_plan_by_id() - Get details for a specific training plan
  • get_adaptive_training_plan_by_id() - Get details for adaptive training plans

There are more training plan endpoints, like Strength Training Plans, Phased Training Plans, but this is a start.

Summary by CodeRabbit

  • New Features

    • Added a “📅 Training Plans” top-level menu.
    • Browse all available training plans.
    • Retrieve a specific training plan by ID, including adaptive plans when applicable.
  • Documentation

    • README updated to include the Training Plans category, API coverage (3 methods), and adjusted category count.

Copy link
Contributor

coderabbitai bot commented Sep 23, 2025

Warning

.coderabbit.yaml has a parsing error

The CodeRabbit configuration file in this repository has a parsing error and default settings were used instead. Please fix the error(s) in the configuration file. You can initialize chat with CodeRabbit to get help with the configuration file.

💥 Parsing errors (1)
Validation error: Invalid enum value. Expected 'chill' | 'assertive', received 'pythonic' at "reviews.profile"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Walkthrough

Adds a "Training Plans" category to README and demo CLI; introduces three Garmin methods and a base URL attribute to fetch training plans and adaptive training plans by ID; CLI routes and display calls updated to include listing and fetching by ID.

Changes

Cohort / File(s) Summary
Documentation update
README.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 / demo
demo.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 Plans
garminconnect/__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
Loading

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Plans
garminconnect/__init__.py (1)

269-270: Align attribute naming with existing garmin_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 trainingPlanList is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 749c248 and b1b506b.

📒 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 connectapi look 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.

nickknissen and others added 2 commits September 24, 2025 08:39
Based on CodeRabbit feedback

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between df5cfd0 and 2cf259f.

📒 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 menu

Category wiring and option keys look consistent with the rest of the menu system.


3190-3195: API method wiring — LGTM

Both new keys are correctly mapped; get_training_plan_by_id uses the helper that routes adaptive plans.

@alexsanjoseph
Copy link

alexsanjoseph commented Oct 3, 2025

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.

@nickknissen
Copy link
Author

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

@alexsanjoseph
Copy link

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
Screenshot 2025-10-03 at 14 23 40

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants