Skip to content

Conversation

@matin
Copy link
Owner

@matin matin commented Jun 11, 2025

Summary

  • Add DailyTrainingStatus, WeeklyTrainingStatus, MonthlyTrainingStatus classes
  • Custom parsing logic for complex nested API responses
  • Comprehensive test coverage with VCR cassettes
  • Documentation in README with usage examples

API Endpoints Added

  • Daily: /mobile-gateway/usersummary/trainingstatus/latest/{date}
  • Weekly: /mobile-gateway/usersummary/trainingstatus/weekly/{start}/{end}
  • Monthly: /mobile-gateway/usersummary/trainingstatus/monthly/{start}/{end}

Implementation Details

  • Follows existing patterns in the stats module for consistency
  • Takes advantage of current base classes to keep code DRY
  • Custom parsing logic handles the complex nested response structure
  • All fields are properly typed with Pydantic dataclasses

Test Coverage

  • 5 comprehensive tests covering daily, weekly, monthly endpoints
  • Tests include pagination scenarios and edge cases
  • VCR cassettes recorded for reliable test execution

Closes #129

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Introduced support for retrieving daily, weekly, and monthly training status data, including new data classes and methods for each interval.
    • Users can now access detailed training status metrics such as load, trends, feedback, and ACWR statistics for different periods.
  • Documentation

    • Added comprehensive usage examples and sample outputs for training status retrieval to the documentation.
    • Provided detailed developer guidance and architectural overview in a new contributor guide.
  • Tests

    • Implemented extensive tests to ensure correct retrieval, parsing, and robustness of training status data, including edge and error cases.

- Add DailyTrainingStatus, WeeklyTrainingStatus, MonthlyTrainingStatus classes
- Custom parsing logic for complex nested API responses
- Comprehensive test coverage with VCR cassettes
- Documentation in README with usage examples
- Follows existing patterns for consistency and DRY principles

Closes #129

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jun 11, 2025

"""

Walkthrough

This change introduces support for retrieving daily, weekly, and monthly training status data from the Garmin Connect API. It adds new dataclasses to model each interval, implements data extraction and pagination logic, updates public exports, extends documentation with usage examples, and provides comprehensive tests for the new functionality and edge cases.

Changes

File(s) Change Summary
src/garth/stats/training_status/daily.py, weekly.py, monthly.py Added new dataclasses (DailyTrainingStatus, WeeklyTrainingStatus, MonthlyTrainingStatus) with API request logic, data extraction, and pagination for each training status interval.
src/garth/stats/training_status/__init__.py Added __all__ and imported the three new training status classes for package-level export.
src/garth/stats/__init__.py, src/garth/__init__.py Updated public API exports to include the new training status classes.
README.md Added a "Training Status" section with usage examples and sample outputs for daily, weekly, and monthly training status retrieval.
CLAUDE.md Added a guidance document for Claude Code, including architecture, coding patterns, and instructions for adding new stats endpoints.
tests/stats/test_training_status.py Added new tests covering normal operation, edge cases, error handling, and pagination for the training status classes.
src/garth/stats/_base.py Refactored Stats.list method to delegate API response parsing to a new _parse_response method, enabling subclasses to customize response handling.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant StatsClass as Daily/Weekly/MonthlyTrainingStatus
    participant Client as HTTP Client
    participant API as Garmin Connect API

    User->>StatsClass: .list(end, period, client)
    StatsClass->>Client: GET /mobile-gateway/usersummary/trainingstatus/{interval}/...
    Client->>API: HTTP request
    API-->>Client: JSON response
    Client-->>StatsClass: Response data
    StatsClass->>StatsClass: Extract and flatten data
    StatsClass->>User: List of TrainingStatus objects
Loading

Assessment against linked issues

Objective Addressed Explanation
Implement daily training status retrieval (/mobile-gateway/usersummary/trainingstatus/latest/{date}) (#129)
Implement weekly training status retrieval (/mobile-gateway/usersummary/trainingstatus/weekly/{start}/{end}) (#129)
Implement monthly training status retrieval (/mobile-gateway/usersummary/trainingstatus/monthly/{start}/{end}) (#129)

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
Addition of CLAUDE.md (CLAUDE.md) This is a documentation file for Claude Code and not directly related to the training status feature.
"""

📜 Recent review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8ceb119 and dab6868.

⛔ Files ignored due to path filters (5)
  • tests/stats/cassettes/test_daily_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_monthly_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_monthly_training_status_no_data.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_weekly_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_weekly_training_status_pagination.yaml is excluded by !tests/**/cassettes/**
📒 Files selected for processing (4)
  • README.md (1 hunks)
  • src/garth/stats/_base.py (3 hunks)
  • src/garth/stats/training_status/daily.py (1 hunks)
  • tests/stats/test_training_status.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • README.md
  • src/garth/stats/_base.py
  • tests/stats/test_training_status.py
🧰 Additional context used
🪛 Pylint (3.3.7)
src/garth/stats/training_status/daily.py

[refactor] 10-10: Too many instance attributes (23/7)

(R0902)

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate Unit Tests
  • Create PR with Unit Tests
  • Commit Unit Tests in branch add-training-status
  • Post Copyable Unit Tests in 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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai auto-generate unit tests to generate unit tests for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@codecov
Copy link

codecov bot commented Jun 11, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00%. Comparing base (c1bdee8) to head (dab6868).
Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##              main      #130    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           43        48     +5     
  Lines         1793      2083   +290     
==========================================
+ Hits          1793      2083   +290     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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

🔭 Outside diff range comments (1)
tests/stats/test_training_status.py (1)

58-66: ⚠️ Potential issue

Assert that the list is empty and add missing newline.

The test should explicitly verify that no data is returned for the old date. Additionally, the file is missing a newline at the end.

 def test_monthly_training_status_no_data(authed_client: Client):
     end = date(2020, 1, 1)  # Date far in the past with no data
     monthly_training_status = MonthlyTrainingStatus.list(
         end, 1, client=authed_client
     )
     # Should return empty list if no data
-    assert isinstance(monthly_training_status, list)
+    assert monthly_training_status == []
+
🧹 Nitpick comments (8)
src/garth/sso.py (1)

80-86: Verify Python ≥ 3.9 requirement & drop the dict() wrapper

The | union operator only works on Python 3.9+. Double-check that pyproject.toml / setup.cfg already advertises a python_requires >= "3.9" (or bump it) so users on 3.8 don’t get a syntax error.

While you’re here, the extra dict() layer is unnecessary and slightly hurts readability. You can inline a literal:

-    SIGNIN_PARAMS = SSO_EMBED_PARAMS | dict(
-        gauthHost=SSO_EMBED,
-        service=SSO_EMBED,
-        source=SSO_EMBED,
-        redirectAfterAccountLoginUrl=SSO_EMBED,
-        redirectAfterAccountCreationUrl=SSO_EMBED,
-    )
+    SIGNIN_PARAMS = SSO_EMBED_PARAMS | {
+        "gauthHost": SSO_EMBED,
+        "service": SSO_EMBED,
+        "source": SSO_EMBED,
+        "redirectAfterAccountLoginUrl": SSO_EMBED,
+        "redirectAfterAccountCreationUrl": SSO_EMBED,
+    }
🧰 Tools
🪛 Pylint (3.3.7)

[refactor] 80-86: Consider using '{"gauthHost": SSO_EMBED, "service": SSO_EMBED, "source": SSO_EMBED, ... }' instead of a call to 'dict'.

(R1735)

src/garth/__init__.py (1)

31-34: (Nit) keep __all__ alphabetised

Pure style, but alphabetising __all__ simplifies diff noise later.

Not blocking.

Also applies to: 40-40

README.md (1)

621-734: Docs example is great – minor parameter inconsistency

Earlier examples use .list("date", pages) whereas the new training-status snippets use .list(period=1) without a start date. If the API truly differs, maybe add a short note (“DailyTrainingStatus.list() takes only period”) to avoid confusion for readers copy-pasting from other sections.

CLAUDE.md (1)

81-90: Redundant heading sentence

The heading “### Adding New Stats Endpoints” already states the topic; the next line repeats it. Consider deleting the sentence for brevity.

-When adding new stats endpoints (like training status):
+When adding new stats endpoints (like training status):

(or just remove the duplicated phrase entirely)

🧰 Tools
🪛 LanguageTool

[uncategorized] ~83-~83: Possible missing comma found.
Context: ...ng New Stats Endpoints When adding new stats endpoints (like training status): 1. C...

(AI_HYDRA_LEO_MISSING_COMMA)

tests/stats/test_training_status.py (2)

24-33: Consider asserting the expected number of weekly results.

While the test verifies non-empty results, it would be more robust to assert the expected number of entries (e.g., assert len(weekly_training_status) == 4 if the API returns exactly 4 weeks).


48-56: Enhance pagination test to verify multi-page retrieval.

The test requests 60 weeks (exceeding the page size of 52) but only checks for non-empty results. Consider adding assertions to verify that pagination actually occurred, such as checking for a minimum number of results or verifying data spans the expected time range.

 def test_weekly_training_status_pagination(authed_client: Client):
     end = date(2025, 6, 11)
     weeks = 60
     weekly_training_status = WeeklyTrainingStatus.list(
         end, weeks, client=authed_client
     )
     assert len(weekly_training_status) > 0
+    # Verify we got data spanning multiple pages
+    # (exact count may vary based on data availability)
+    assert len(weekly_training_status) > 52  # More than one page
src/garth/stats/training_status.py (2)

66-74: Document the single-device limitation more prominently.

The code assumes a single device in multiple places (lines 66 and 82). This limitation should be documented in the class or method docstring to make it clear to users. Consider adding a TODO for future multi-device support.

Would you like me to help implement multi-device support or create an issue to track this enhancement?

Also applies to: 82-93

🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 74-74: src/garth/stats/training_status.py#L74
Added line #L74 was not covered by tests


154-159: Add missing newline at end of file.

The file is missing a newline character at the end.

 class MonthlyTrainingStatus(TrainingStatus):
     _path: ClassVar[str] = f"{BASE_PATH}/monthly/{{start}}/{{end}}"
     _data_key: ClassVar[str] = "monthlyTrainingStatus"
     _page_size: ClassVar[int] = 12
+
📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c1bdee8 and a9305ce.

⛔ Files ignored due to path filters (5)
  • tests/stats/cassettes/test_daily_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_monthly_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_monthly_training_status_no_data.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_weekly_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_weekly_training_status_pagination.yaml is excluded by !tests/**/cassettes/**
📒 Files selected for processing (7)
  • CLAUDE.md (1 hunks)
  • README.md (1 hunks)
  • src/garth/__init__.py (2 hunks)
  • src/garth/sso.py (1 hunks)
  • src/garth/stats/__init__.py (2 hunks)
  • src/garth/stats/training_status.py (1 hunks)
  • tests/stats/test_training_status.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
`tests/**`: - test functions shouldn't have a return type hint - it's ok to use `assert` instead of `pytest.assume()`

tests/**: - test functions shouldn't have a return type hint

  • it's ok to use assert instead of pytest.assume()
  • tests/stats/test_training_status.py
🧬 Code Graph Analysis (3)
src/garth/stats/__init__.py (1)
src/garth/stats/training_status.py (3)
  • DailyTrainingStatus (141-144)
  • MonthlyTrainingStatus (155-158)
  • WeeklyTrainingStatus (148-151)
src/garth/__init__.py (1)
src/garth/stats/training_status.py (3)
  • DailyTrainingStatus (141-144)
  • MonthlyTrainingStatus (155-158)
  • WeeklyTrainingStatus (148-151)
tests/stats/test_training_status.py (3)
src/garth/stats/training_status.py (3)
  • DailyTrainingStatus (141-144)
  • MonthlyTrainingStatus (155-158)
  • WeeklyTrainingStatus (148-151)
src/garth/http.py (1)
  • Client (19-244)
tests/conftest.py (3)
  • vcr (79-82)
  • authed_client (65-75)
  • client (21-22)
🪛 Pylint (3.3.7)
src/garth/sso.py

[refactor] 80-86: Consider using '{"gauthHost": SSO_EMBED, "service": SSO_EMBED, "source": SSO_EMBED, ... }' instead of a call to 'dict'.

(R1735)

src/garth/stats/training_status.py

[refactor] 15-15: Too many instance attributes (24/7)

(R0902)


[refactor] 46-46: Too many return statements (9/6)

(R0911)


[refactor] 46-46: Too many branches (14/12)

(R0912)

🪛 GitHub Check: codecov/patch
src/garth/stats/training_status.py

[warning] 51-51: src/garth/stats/training_status.py#L51
Added line #L51 was not covered by tests


[warning] 55-55: src/garth/stats/training_status.py#L55
Added line #L55 was not covered by tests


[warning] 59-59: src/garth/stats/training_status.py#L59
Added line #L59 was not covered by tests


[warning] 65-65: src/garth/stats/training_status.py#L65
Added line #L65 was not covered by tests


[warning] 74-74: src/garth/stats/training_status.py#L74
Added line #L74 was not covered by tests


[warning] 79-79: src/garth/stats/training_status.py#L79
Added line #L79 was not covered by tests


[warning] 111-111: src/garth/stats/training_status.py#L111
Added line #L111 was not covered by tests


[warning] 126-126: src/garth/stats/training_status.py#L126
Added line #L126 was not covered by tests

🪛 LanguageTool
CLAUDE.md

[uncategorized] ~83-~83: Possible missing comma found.
Context: ...ng New Stats Endpoints When adding new stats endpoints (like training status): 1. C...

(AI_HYDRA_LEO_MISSING_COMMA)

🔇 Additional comments (5)
src/garth/__init__.py (1)

10-16: Public exports look correct

The new training-status classes are imported and will be exposed to users, matching the additions in stats/__init__.py. LGTM.

src/garth/stats/__init__.py (1)

8-14: Exports consistent across package

DailyTrainingStatus, WeeklyTrainingStatus, and MonthlyTrainingStatus are now publicly available here and at package root. Nice consistency.

Also applies to: 22-26

tests/stats/test_training_status.py (1)

13-22: LGTM! Well-structured test for daily training status.

The test correctly validates the basic functionality of retrieving a single day's training status data.

src/garth/stats/training_status.py (2)

14-40: Appropriate design for API data model.

The large number of attributes is justified as this is a data transfer object matching the API response structure. The use of optional fields provides flexibility for partial responses.

🧰 Tools
🪛 Pylint (3.3.7)

[refactor] 15-15: Too many instance attributes (24/7)

(R0902)


96-138: Well-implemented pagination logic.

The recursive approach to handle pagination is clean and effective. The method properly handles date calculations and API responses.

🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 111-111: src/garth/stats/training_status.py#L111
Added line #L111 was not covered by tests


[warning] 126-126: src/garth/stats/training_status.py#L126
Added line #L126 was not covered by tests

- Test error cases in _extract_training_data method
- Test error cases in list method for non-dict responses
- Test pagination when first page is empty
- Achieve 100% test coverage for training status module

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
Copy link
Owner Author

@matin matin left a comment

Choose a reason for hiding this comment

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

@claude mostly there, but there are some changes I commented on

matin and others added 3 commits June 11, 2025 16:37
- Split single training_status.py into separate daily/weekly/monthly modules
- Use existing Stats base class instead of duplicating functionality
- Follow body battery pattern for directory structure
- Add comprehensive error handling tests achieving 100% coverage
- Fix import paths and typing issues for the new structure

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
Remove problematic list[dict] return type annotations that cause
issues with mypy type checking in CI environments.
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: 2

🧹 Nitpick comments (3)
src/garth/stats/training_status/daily.py (1)

38-42: _page_size is dead code in the daily implementation

_page_size is never referenced in DailyTrainingStatus.
Because the /latest/{end} endpoint always returns a single day, the
attribute is misleading noise.

-    _page_size: ClassVar[int] = 28

Unless you plan to add real pagination, drop the constant to avoid
confusion.

tests/stats/test_training_status.py (1)

225-233: Unreachable else block – small readability clean-up

The else after a return is redundant. Dropping it shortens the
test helper and silences the linter warning.

-    def mock_connectapi_side_effect(path):
-        # First call returns data, subsequent calls return empty
-        if hasattr(mock_connectapi_side_effect, "call_count"):
-            mock_connectapi_side_effect.call_count += 1
-            return {}
-        else:
-            mock_connectapi_side_effect.call_count = 1
-            return mock_response
+    def mock_connectapi_side_effect(path):
+        # First call returns data, subsequent calls return empty
+        if hasattr(mock_connectapi_side_effect, "call_count"):
+            mock_connectapi_side_effect.call_count += 1
+            return {}
+        mock_connectapi_side_effect.call_count = 1
+        return mock_response
🧰 Tools
🪛 Pylint (3.3.7)

[refactor] 227-232: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)

CLAUDE.md (1)

82-85: Minor punctuation nit

Missing comma after “endpoints”. Helps the long sentence breathe.

-When adding new stats endpoints (like training status):
+When adding new stats endpoints, (like training status):
🧰 Tools
🪛 LanguageTool

[uncategorized] ~83-~83: Possible missing comma found.
Context: ...ng New Stats Endpoints When adding new stats endpoints (like training status): 1. C...

(AI_HYDRA_LEO_MISSING_COMMA)

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 859e14c and 9893c9a.

📒 Files selected for processing (6)
  • CLAUDE.md (1 hunks)
  • src/garth/stats/training_status/__init__.py (1 hunks)
  • src/garth/stats/training_status/daily.py (1 hunks)
  • src/garth/stats/training_status/monthly.py (1 hunks)
  • src/garth/stats/training_status/weekly.py (1 hunks)
  • tests/stats/test_training_status.py (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • src/garth/stats/training_status/init.py
🧰 Additional context used
📓 Path-based instructions (1)
`tests/**`: - test functions shouldn't have a return type hint - it's ok to use `assert` instead of `pytest.assume()`

tests/**: - test functions shouldn't have a return type hint

  • it's ok to use assert instead of pytest.assume()
  • tests/stats/test_training_status.py
🪛 Pylint (3.3.7)
src/garth/stats/training_status/monthly.py

[refactor] 13-13: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/weekly.py

[refactor] 13-13: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/daily.py

[refactor] 13-13: Too many instance attributes (23/7)

(R0902)

tests/stats/test_training_status.py

[refactor] 227-232: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)

🪛 LanguageTool
CLAUDE.md

[uncategorized] ~83-~83: Possible missing comma found.
Context: ...ng New Stats Endpoints When adding new stats endpoints (like training status): 1. C...

(AI_HYDRA_LEO_MISSING_COMMA)

🔇 Additional comments (1)
src/garth/stats/training_status/weekly.py (1)

55-65: Pagination order looks reversed

page fetches the most-recent 52 weeks; remaining_page then fetches
older data and you return remaining_page + page, meaning the list is
oldest-to-newest. Confirm this is the intended order (other
Stats.list() implementations return newest-to-oldest).

- Extend Stats base class with _parse_response() hook method
- Remove 101 lines of duplicated code (33% reduction) from training status
- Eliminate duplicate pagination, HTTP client, and formatting logic
- Training status classes now only implement custom response parsing
- Maintain 100% backward compatibility and test coverage
- Follow Template Method pattern for better maintainability

This refactoring demonstrates strategic use of inheritance to eliminate
code duplication while maintaining flexibility for custom API response
parsing in the training status modules.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
@matin
Copy link
Owner Author

matin commented Jun 11, 2025

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jun 11, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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

🔭 Outside diff range comments (1)
src/garth/stats/_base.py (1)

28-45: ⚠️ Potential issue

Monthly periods are computed in weeks – date ranges are wrong
The period_type fallback treats every non-daily class as weekly.
MonthlyTrainingStatus.list(..., period=6) therefore covers only 5×7 days
instead of ~5 months, and pagination subtracts _page_size weeks rather than
months.

-        period_type = "days" if "daily" in cls._path else "weeks"
+        if "daily" in cls._path:
+            period_type = "days"
+        elif "monthly" in cls._path:
+            period_type = "months"
+        else:       # weekly or other interval
+            period_type = "weeks"

When period_type == "months" you must switch from datetime.timedelta
(which has no months) to dateutil.relativedelta.relativedelta. A minimal,
drop-in patch:

-from datetime import date, timedelta
+from datetime import date, timedelta
+from dateutil.relativedelta import relativedelta
@@
-        start = end - timedelta(**{period_type: period - 1})
+        delta = relativedelta if period_type == "months" else timedelta
+        start = end - delta(**{period_type: period - 1})

and the identical change in the recursive branch (end - delta(...)).
Without this, monthly endpoints silently return inaccurate data.

♻️ Duplicate comments (2)
src/garth/stats/training_status/monthly.py (1)

57-68: Same single-device limitation as weekly parser
See the weekly parser comment – only the first device block is emitted.
Returning all devices’ monthly data would be more faithful to the API.

src/garth/stats/training_status/daily.py (1)

34-38: 🛠️ Refactor suggestion

_path ignores period, causing odd behaviour for period>1
/trainingstatus/latest/{end} can only ever return one day. When callers pass
period > 1, Stats.list repeatedly hits the same latest endpoint with
earlier end dates, which almost certainly yields empty pages and increases
API traffic.

If historical daily data is not available, restrict the public API:

-    def list(cls, end: date | str | None = None, period: int = 1, *, …)
+    def list(cls, end: date | str | None = None, *, …)

or document clearly that only period == 1 is supported.

🧹 Nitpick comments (2)
src/garth/stats/_base.py (1)

63-68: Avoid mutating the source dict while flattening "values"
stat.pop("values") alters the original object that may be reused by
callers/tests. A non-destructive merge is safer:

-        page_dirs = [{**stat, **stat.pop("values")} for stat in page_dirs]
+        page_dirs = [
+            {**{k: v for k, v in stat.items() if k != "values"}, **stat["values"]}
+            for stat in page_dirs
+        ]

This keeps page_dirs independent of the raw API payload.

tests/stats/test_training_status.py (1)

225-233: Minor style nit – else after return is redundant

if hasattr(mock_connectapi_side_effect, "call_count"):
    mock_connectapi_side_effect.call_count += 1
    return {}
else:                       # ← can be deleted
    mock_connectapi_side_effect.call_count = 1
    return mock_response

Removing the else shortens nesting and appeases pylint R1705.

🧰 Tools
🪛 Pylint (3.3.7)

[refactor] 227-232: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9893c9a and 02a3774.

📒 Files selected for processing (5)
  • src/garth/stats/_base.py (1 hunks)
  • src/garth/stats/training_status/daily.py (1 hunks)
  • src/garth/stats/training_status/monthly.py (1 hunks)
  • src/garth/stats/training_status/weekly.py (1 hunks)
  • tests/stats/test_training_status.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
`tests/**`: - test functions shouldn't have a return type hint - it's ok to use `assert` instead of `pytest.assume()`

tests/**: - test functions shouldn't have a return type hint

  • it's ok to use assert instead of pytest.assume()
  • tests/stats/test_training_status.py
🧬 Code Graph Analysis (2)
src/garth/stats/training_status/weekly.py (3)
src/garth/stats/_base.py (2)
  • Stats (12-68)
  • _parse_response (57-68)
src/garth/stats/training_status/monthly.py (1)
  • _parse_response (40-70)
src/garth/stats/training_status/daily.py (1)
  • _parse_response (40-66)
src/garth/stats/_base.py (5)
src/garth/http.py (1)
  • connectapi (186-192)
src/garth/stats/training_status/monthly.py (1)
  • _parse_response (40-70)
src/garth/stats/training_status/daily.py (1)
  • _parse_response (40-66)
src/garth/stats/training_status/weekly.py (1)
  • _parse_response (40-70)
src/garth/utils.py (1)
  • camel_to_snake_dict (17-33)
🪛 Pylint (3.3.7)
src/garth/stats/training_status/weekly.py

[refactor] 9-9: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/daily.py

[refactor] 9-9: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/monthly.py

[refactor] 9-9: Too many instance attributes (23/7)

(R0902)

tests/stats/test_training_status.py

[refactor] 227-232: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)

🔇 Additional comments (1)
src/garth/stats/training_status/monthly.py (1)

34-38: Pagination size (12) is correct but Stats.list still works in weeks
Even with _page_size = 12, the base class bug (see earlier comment) will
subtract 12 weeks, not 12 months. Ensure the base-class fix is applied, or
MonthlyTrainingStatus.list(period>12) will request an 84-day window instead
of a 1-year window.

Comment on lines +57 to +68
# Get the first device's data (assumes single device for now)
for device_data in report_data.values():
if isinstance(device_data, list):
result = []
for entry in device_data:
if isinstance(entry, dict):
# Flatten the acuteTrainingLoadDTO data
acute_load = entry.pop("acuteTrainingLoadDTO", {})
if isinstance(acute_load, dict):
entry.update(acute_load)
result.append(entry)
return result
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Only the first device’s data is returned – multi-device accounts lose information
for device_data in report_data.values(): return … exits after the first
iteration; if a user owns multiple watches, the remaining devices’ weekly
stats are dropped.

Consider aggregating all device_data lists:

-        for device_data in report_data.values():
-            if isinstance(device_data, list):
-                result = []
-                for entry in device_data:
+        result: list[dict] = []
+        for device_data in report_data.values():
+            if isinstance(device_data, list):
+                for entry in device_data:-                return result
-
-        return []
+        return result

Returning a complete set avoids silent data loss.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Get the first device's data (assumes single device for now)
for device_data in report_data.values():
if isinstance(device_data, list):
result = []
for entry in device_data:
if isinstance(entry, dict):
# Flatten the acuteTrainingLoadDTO data
acute_load = entry.pop("acuteTrainingLoadDTO", {})
if isinstance(acute_load, dict):
entry.update(acute_load)
result.append(entry)
return result
# Get all devices’ data (was only returning the first device)
result: list[dict] = []
for device_data in report_data.values():
if isinstance(device_data, list):
for entry in device_data:
if isinstance(entry, dict):
# Flatten the acuteTrainingLoadDTO data
acute_load = entry.pop("acuteTrainingLoadDTO", {})
if isinstance(acute_load, dict):
entry.update(acute_load)
result.append(entry)
return result
🤖 Prompt for AI Agents
In src/garth/stats/training_status/weekly.py around lines 57 to 68, the code
returns data from only the first device by exiting the loop early, causing loss
of data for users with multiple devices. Modify the code to aggregate the
processed entries from all devices into a single list before returning, ensuring
that data from every device in report_data.values() is included in the final
result.

- Change since_date from str to date type
- Change timestamp from int to datetime type
- Add datetime type validation in tests
- Update README examples to show proper datetime objects
- Resolve CodeRabbit comments on dict() usage and test structure

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
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: 3

♻️ Duplicate comments (3)
src/garth/stats/training_status/weekly.py (1)

58-69: Still returns only the first device’s data – aggregate across all devices
Behaviour unchanged from the previous review: the early return drops data for users who own multiple devices. Please aggregate entries from all device_data lists before returning.

-        # Get the first device's data (assumes single device for now)
-        for device_data in report_data.values():
-            if isinstance(device_data, list):
-                result = []
-                for entry in device_data:
-                    ...
-                return result
+        result: list[dict] = []
+        for device_data in report_data.values():
+            if isinstance(device_data, list):
+                for entry in device_data:
+                    ...
+                    result.append(entry)
+        return result
src/garth/stats/training_status/monthly.py (1)

35-39: 🛠️ Refactor suggestion

⚠️ Potential issue

Period maths are still based on ISO-weeks → monthly ranges are wrong

Because Stats.list() defaults to period_type = "weeks" for every path that is not daily, calls such as MonthlyTrainingStatus.list(end=date(2025, 6, 1), period=6) will only go back 42 days instead of ~6 calendar months.
The same bug affects the recursion branch that subtracts _page_size.

Override list() here (or, better, fix the heuristic in _base.py) and switch to dateutil.relativedelta(months=…).

+from dateutil.relativedelta import relativedelta
+
     _page_size: ClassVar[int] = 12
+
+    # --- override to use calendar months instead of ISO weeks -------------
+    @classmethod
+    def list(
+        cls,
+        end: date | str | None = None,
+        period: int = 1,
+        *,
+        client: http.Client | None = None,
+    ):
+        client = client or http.client
+        end = format_end_date(end)
+
+        if period > cls._page_size:
+            page = cls.list(end, cls._page_size, client=client)
+            if not page:
+                return []
+            page = (
+                cls.list(
+                    end - relativedelta(months=cls._page_size),
+                    period - cls._page_size,
+                    client=client,
+                )
+                + page
+            )
+            return page
+
+        start = end - relativedelta(months=period - 1)
+        path = cls._path.format(start=start, end=end, period=period)
+        response = client.connectapi(path)
+        stats = [camel_to_snake_dict(s) for s in cls._parse_response(response)]
+        return [cls(**s) for s in stats]

Without this fix every consumer will silently receive truncated data.

src/garth/stats/training_status/daily.py (1)

40-67: period argument still functionally ignored – comment from previous review applies

DailyTrainingStatus relies on an endpoint that always returns a single
day’s record, so Stats.list(..., period=n) never yields n items.
Either implement iterative fetching (one call per date) or drop the
parameter from the public API to stay truthful.

(Tagging as duplicate because this was already raised on an earlier
commit.)

🧹 Nitpick comments (3)
src/garth/sso.py (1)

159-159: Minor nit – optional empty body

data = {} works, but when oauth1.mfa_token is falsy you could pass None to avoid sending an empty form body:

-    data = {"mfa_token": oauth1.mfa_token} if oauth1.mfa_token else {}
+    data = {"mfa_token": oauth1.mfa_token} if oauth1.mfa_token else None

Not critical, just tidies the request.

src/garth/stats/training_status/weekly.py (1)

40-71: Parsing logic duplicated across daily / weekly / monthly – consider a helper
_parse_response in the three training-status classes follows the same nested-dict pattern. An internal utility (e.g. _flatten_training_status(response, *, level_keys) ) would cut duplication and future maintenance cost.

src/garth/stats/training_status/monthly.py (1)

41-71: Parsing logic mirrors the weekly implementation — 👍 but consider defensive copy

The extractor correctly flattens acuteTrainingLoadDTO and copes with unexpected shapes.
Minor: entry.pop("acuteTrainingLoadDTO", {}) mutates the original API dictionary; use entry.get and merge into a new dict to avoid side-effects if the caller reuses the raw response elsewhere.

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 02a3774 and 0df1aa9.

📒 Files selected for processing (6)
  • README.md (1 hunks)
  • src/garth/sso.py (3 hunks)
  • src/garth/stats/training_status/daily.py (1 hunks)
  • src/garth/stats/training_status/monthly.py (1 hunks)
  • src/garth/stats/training_status/weekly.py (1 hunks)
  • tests/stats/test_training_status.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • README.md
  • tests/stats/test_training_status.py
🧰 Additional context used
🧬 Code Graph Analysis (3)
src/garth/stats/training_status/daily.py (3)
src/garth/stats/_base.py (2)
  • Stats (12-68)
  • _parse_response (57-68)
src/garth/stats/training_status/weekly.py (1)
  • _parse_response (41-71)
src/garth/stats/training_status/monthly.py (1)
  • _parse_response (41-71)
src/garth/stats/training_status/monthly.py (3)
src/garth/stats/_base.py (2)
  • Stats (12-68)
  • _parse_response (57-68)
src/garth/stats/training_status/weekly.py (1)
  • _parse_response (41-71)
src/garth/stats/training_status/daily.py (1)
  • _parse_response (41-67)
src/garth/stats/training_status/weekly.py (3)
src/garth/stats/_base.py (2)
  • Stats (12-68)
  • _parse_response (57-68)
src/garth/stats/training_status/daily.py (1)
  • _parse_response (41-67)
src/garth/stats/training_status/monthly.py (1)
  • _parse_response (41-71)
🪛 Pylint (3.3.7)
src/garth/stats/training_status/daily.py

[refactor] 10-10: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/monthly.py

[refactor] 10-10: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/weekly.py

[refactor] 10-10: Too many instance attributes (23/7)

(R0902)

🔇 Additional comments (4)
src/garth/sso.py (3)

75-79: Consistent dict-literal usage looks good

Switching from dict() to literal {} is more idiomatic and a hair faster; no behavioural change introduced.


82-88: Overriding gauthHost key is unchanged – all good

The merge correctly overrides the earlier gauthHost value with SSO_EMBED. Behaviour matches previous code.


109-114: Style-only change confirmed

The POST payload is identical to the earlier implementation; nothing else to flag.

src/garth/stats/training_status/monthly.py (1)

9-34: Large dataclass field set is expected – ignore pylint R0902

This dataclass is a straight mirror of Garmin’s payload; the high attribute count is acceptable and suppressing the pylint warning is fine.

🧰 Tools
🪛 Pylint (3.3.7)

[refactor] 10-10: Too many instance attributes (23/7)

(R0902)

Comment on lines +35 to +38
_path: ClassVar[str] = (
"/mobile-gateway/usersummary/trainingstatus/latest/{end}"
)
_page_size: ClassVar[int] = 28
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

_path lacks “daily” – date math is done in weeks, breaking multi-day queries

Stats.list() decides whether to subtract timedelta(days=…) or timedelta(weeks=…) by checking if the substring "daily" is present in cls._path.
Because this path is …/trainingstatus/latest/{end} (no “daily”), period_type is set to "weeks", so:

start = end - timedelta(weeks=period-1)   # ← wrong unit

DailyTrainingStatus.list(end, period=3) therefore fetches the wrong
dates.

Fix options:

-_path: ClassVar[str] = (
-    "/mobile-gateway/usersummary/trainingstatus/latest/{end}"
-)
+# Keep behaviour aligned with the heuristic in Stats.list()
+_path: ClassVar[str] = (
+    "/mobile-gateway/usersummary/trainingstatus/daily/{end}"
+)

or override list() in this subclass and force period_type = "days".

Either way, ensure the unit test covers period > 1.

🤖 Prompt for AI Agents
In src/garth/stats/training_status/daily.py around lines 35 to 38, the _path
string lacks the substring "daily", causing Stats.list() to use weeks instead of
days for date calculations, which breaks multi-day queries. To fix this, update
the _path to include "daily" so the period_type is correctly set to "days", or
alternatively override the list() method in this subclass to explicitly set
period_type to "days". Also, add or update unit tests to cover cases where
period > 1 to verify correct behavior.

Comment on lines +40 to +67
@classmethod
def _parse_response(cls, response):
"""Extract training data from the daily API response structure."""
if not isinstance(response, dict):
return []

data_section = response.get("mostRecentTrainingStatus", {})
if not isinstance(data_section, dict):
return []

payload = data_section.get("payload", {})
if not isinstance(payload, dict):
return []

latest_data = payload.get("latestTrainingStatusData", {})
if not isinstance(latest_data, dict):
return []

# Get the first device's data (assumes single device for now)
for device_data in latest_data.values():
if isinstance(device_data, dict):
# Flatten the acuteTrainingLoadDTO data
acute_load = device_data.pop("acuteTrainingLoadDTO", {})
if isinstance(acute_load, dict):
device_data.update(acute_load)
return [device_data]

return []
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Parser drops data from secondary devices – only the first device makes it through

_parse_response() stops after the first value in latestTrainingStatusData.
If the user owns multiple devices (common with Garmin), their additional
records are silently discarded.

Minimal change:

-        for device_data in latest_data.values():
-            if isinstance(device_data, dict):
-
-                return [device_data]
-        return []
+        result: list[dict] = []
+        for device_data in latest_data.values():
+            if isinstance(device_data, dict):
+                acute_load = device_data.pop("acuteTrainingLoadDTO", {}) or {}
+                if isinstance(acute_load, dict):
+                    device_data.update(acute_load)
+                result.append(device_data)
+        return result

This keeps the method contract (list[dict]) while preserving all device
entries.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@classmethod
def _parse_response(cls, response):
"""Extract training data from the daily API response structure."""
if not isinstance(response, dict):
return []
data_section = response.get("mostRecentTrainingStatus", {})
if not isinstance(data_section, dict):
return []
payload = data_section.get("payload", {})
if not isinstance(payload, dict):
return []
latest_data = payload.get("latestTrainingStatusData", {})
if not isinstance(latest_data, dict):
return []
# Get the first device's data (assumes single device for now)
for device_data in latest_data.values():
if isinstance(device_data, dict):
# Flatten the acuteTrainingLoadDTO data
acute_load = device_data.pop("acuteTrainingLoadDTO", {})
if isinstance(acute_load, dict):
device_data.update(acute_load)
return [device_data]
return []
@classmethod
def _parse_response(cls, response):
"""Extract training data from the daily API response structure."""
if not isinstance(response, dict):
return []
data_section = response.get("mostRecentTrainingStatus", {})
if not isinstance(data_section, dict):
return []
payload = data_section.get("payload", {})
if not isinstance(payload, dict):
return []
latest_data = payload.get("latestTrainingStatusData", {})
if not isinstance(latest_data, dict):
return []
result: list[dict] = []
for device_data in latest_data.values():
if isinstance(device_data, dict):
acute_load = device_data.pop("acuteTrainingLoadDTO", {}) or {}
if isinstance(acute_load, dict):
device_data.update(acute_load)
result.append(device_data)
return result
🤖 Prompt for AI Agents
In src/garth/stats/training_status/daily.py between lines 40 and 67, the
_parse_response method currently returns only the first device's data from
latestTrainingStatusData, discarding any additional devices. To fix this, modify
the method to iterate over all device_data entries in latestTrainingStatusData,
flatten each acuteTrainingLoadDTO into its device_data, and collect all
processed device_data dictionaries into a list that is returned at the end,
ensuring all devices' data are preserved and returned as a list of dicts.

matin and others added 2 commits June 11, 2025 19:00
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
- Add _period_type class variable to Stats base class for explicit override
- Set DailyTrainingStatus._period_type = "days" to fix date calculations
- Add test to verify period type is correctly applied
- Regenerate VCR cassettes with correct API calls
- Update README examples to use consistent parameter format
- Maintains backward compatibility with existing stats classes

Resolves CodeRabbit potential issue: daily training status was using weeks
instead of days for date math due to path not containing "daily" keyword.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
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.

add training status

2 participants