From dc45f3322afce8bf744393a275fd366f0e71de29 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Thu, 6 Aug 2026 18:37:05 -0500 Subject: [PATCH 1/3] feat: add workout trends API (gateway endpoints from app v5.6.0) Add support for the new OTF workout trends/stats endpoints discovered in the Android app v5.6.0. These use a new API surface at api.gateway.orangetheory.com with two endpoints: - GET /consumer-mobile/v1/users/me/workout-stats/{statsKey} - GET /consumer-mobile/v1/users/me/workout-stats/preview Available stat keys: splat_points, average_hr, peak_hr, tread_top_speed, rower_500m_split_time, rower_top_power. New files: - api/trends/ (TrendsClient + TrendsApi) - models/trends/ (TrendType, TrendCategory, StatPoint, etc.) Usage: otf.trends.get_workout_stats(TrendType.SplatPoints) otf.trends.get_workout_stats_preview(workout_count=10) --- CLAUDE.md | 18 ++++++- src/otf_api/api/api.py | 3 ++ src/otf_api/api/client.py | 1 + src/otf_api/api/trends/__init__.py | 3 ++ src/otf_api/api/trends/trend_api.py | 70 ++++++++++++++++++++++++++ src/otf_api/api/trends/trend_client.py | 42 ++++++++++++++++ src/otf_api/models/__init__.py | 14 ++++++ src/otf_api/models/trends/__init__.py | 17 +++++++ src/otf_api/models/trends/trends.py | 67 ++++++++++++++++++++++++ 9 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 src/otf_api/api/trends/__init__.py create mode 100644 src/otf_api/api/trends/trend_api.py create mode 100644 src/otf_api/api/trends/trend_client.py create mode 100644 src/otf_api/models/trends/__init__.py create mode 100644 src/otf_api/models/trends/trends.py diff --git a/CLAUDE.md b/CLAUDE.md index e598a554..dfaabe51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Tests require real OrangeTheory credentials. Set `OTF_EMAIL` and `OTF_PASSWORD` ## API Versioning and Dual Endpoints -OTF has two parallel API surfaces that return overlapping but structurally different data. Understanding which is which is critical before making changes. +OTF has multiple API surfaces that return overlapping but structurally different data. Understanding which is which is critical before making changes. ### V1 ("member" API) vs V2 ("classes" API) @@ -72,6 +72,19 @@ OTF has two parallel API surfaces that return overlapping but structurally diffe - **Studio data varies by endpoint**: The v1 bookings endpoint returns a minimal studio object. `get_classes` and `get_bookings` enrich it by fetching full `StudioDetail` via threaded calls to `/mobile/v1/studios/{uuid}`. - **`OtfClass` comes from the classes endpoint, not the bookings endpoint**: It's enriched with studio data post-fetch. The raw API response has a different studio shape than what the model exposes. +### Gateway API ("consumer-mobile" / trends) + +A third API surface added in app v5.6.0, serving workout trends data. + +| Concept | Gateway | +|---|---| +| Base URL | `api.gateway.orangetheory.com` | +| Client method | `gateway_request()` | +| Endpoints | `GET /consumer-mobile/v1/users/me/workout-stats/{statsKey}`, `GET /consumer-mobile/v1/users/me/workout-stats/preview` | +| Auth | Same Cognito bearer token as v1/v2 | +| Response format | snake_case JSON (unlike the camelCase used by some v1/v2 endpoints) | +| Available stat keys | `splat_points`, `average_hr`, `peak_hr`, `tread_top_speed`, `rower_500m_split_time`, `rower_top_power` | + ### Two-layer architecture (Client → Api) Each domain has a `*Client` (raw HTTP, returns dicts) and a `*Api` (business logic, returns models): @@ -80,8 +93,9 @@ Each domain has a `*Client` (raw HTTP, returns dicts) and a `*Api` (business log - `StudioClient` / `StudioApi` — raw HTTP calls vs typed studio operations - `WorkoutClient` / `WorkoutApi` — raw HTTP calls vs typed workout operations - `MemberClient` / `MemberApi` — raw HTTP calls vs typed member operations +- `TrendsClient` / `TrendsApi` — raw HTTP calls vs typed workout trends/stats operations -The `*Client` classes are internal; the `*Api` classes are what users interact with via `Otf.bookings`, `Otf.studios`, etc. +The `*Client` classes are internal; the `*Api` classes are what users interact with via `Otf.bookings`, `Otf.studios`, `Otf.trends`, etc. ### Testing with fixtures diff --git a/src/otf_api/api/api.py b/src/otf_api/api/api.py index c856d33d..fa163550 100644 --- a/src/otf_api/api/api.py +++ b/src/otf_api/api/api.py @@ -5,6 +5,7 @@ from .client import OtfClient from .members import MemberApi from .studios import StudioApi +from .trends import TrendsApi from .workouts import WorkoutApi @@ -24,6 +25,7 @@ class Otf: members: MemberApi workouts: WorkoutApi studios: StudioApi + trends: TrendsApi def __init__(self, user: OtfUser | None = None): """Initialize the OTF API client. @@ -37,6 +39,7 @@ def __init__(self, user: OtfUser | None = None): self.members = MemberApi(self, client) self.workouts = WorkoutApi(self, client) self.studios = StudioApi(self, client) + self.trends = TrendsApi(self, client) self._member: models.MemberDetail | None = None diff --git a/src/otf_api/api/client.py b/src/otf_api/api/client.py index c8c7a583..1755ee1a 100644 --- a/src/otf_api/api/client.py +++ b/src/otf_api/api/client.py @@ -20,6 +20,7 @@ API_BASE_URL = "api.orangetheory.co" API_IO_BASE_URL = "api.orangetheory.io" API_TELEMETRY_BASE_URL = "api.yuzu.orangetheory.com" +API_GATEWAY_BASE_URL = "api.gateway.orangetheory.com" HEADERS = { "content-type": "application/json", "accept": "application/json", diff --git a/src/otf_api/api/trends/__init__.py b/src/otf_api/api/trends/__init__.py new file mode 100644 index 00000000..48aa275a --- /dev/null +++ b/src/otf_api/api/trends/__init__.py @@ -0,0 +1,3 @@ +from .trend_api import TrendsApi + +__all__ = ["TrendsApi"] diff --git a/src/otf_api/api/trends/trend_api.py b/src/otf_api/api/trends/trend_api.py new file mode 100644 index 00000000..19b8c180 --- /dev/null +++ b/src/otf_api/api/trends/trend_api.py @@ -0,0 +1,70 @@ +import typing +from datetime import date +from logging import getLogger + +import pendulum + +from otf_api.api import utils +from otf_api.models.trends import ( + TrendType, + WorkoutStatsPreviewResponse, + WorkoutStatsResponse, +) + +from .trend_client import TrendsClient + +if typing.TYPE_CHECKING: + from otf_api import Otf + from otf_api.api.client import OtfClient + +LOGGER = getLogger(__name__) + + +class TrendsApi: + """API for retrieving workout trend data from OrangeTheory. + + Provides methods to get per-metric trend data (splat points, heart rate, + treadmill speed, rower stats) over time, as well as a preview across all metrics. + """ + + def __init__(self, otf: "Otf", otf_client: "OtfClient"): + self.otf = otf + self.client = TrendsClient(otf_client) + + def get_workout_stats( + self, + trend_type: TrendType | str, + start_date: date | str | None = None, + end_date: date | str | None = None, + ) -> WorkoutStatsResponse: + """Get detailed workout stats for a specific metric over a date range. + + Args: + trend_type: The trend metric to retrieve (e.g. TrendType.SplatPoints). + start_date: Start of the date range. Defaults to 90 days ago. + end_date: End of the date range. Defaults to today. + + Returns: + WorkoutStatsResponse: The stat data with individual data points per workout. + """ + start = utils.ensure_date(start_date) or pendulum.today().subtract(days=90).date() + end = utils.ensure_date(end_date) or pendulum.today().date() + + start_str = pendulum.instance(pendulum.datetime(start.year, start.month, start.day)).to_iso8601_string() + end_str = pendulum.instance(pendulum.datetime(end.year, end.month, end.day, 23, 59, 59)).to_iso8601_string() + + stats_key = str(trend_type) + data = self.client.get_workout_stats(stats_key, start_str, end_str) + return WorkoutStatsResponse(**data) + + def get_workout_stats_preview(self, workout_count: int = 10) -> WorkoutStatsPreviewResponse: + """Get a preview of workout stats across all trend metrics. + + Args: + workout_count: Number of recent workouts to include. Default is 10. + + Returns: + WorkoutStatsPreviewResponse: Preview data with stats across all metrics. + """ + data = self.client.get_workout_stats_preview(workout_count) + return WorkoutStatsPreviewResponse(**data) diff --git a/src/otf_api/api/trends/trend_client.py b/src/otf_api/api/trends/trend_client.py new file mode 100644 index 00000000..01522761 --- /dev/null +++ b/src/otf_api/api/trends/trend_client.py @@ -0,0 +1,42 @@ +from typing import Any + +from otf_api.api.client import API_GATEWAY_BASE_URL, OtfClient + + +class TrendsClient: + """Client for retrieving workout trends/stats data from the OTF Gateway API.""" + + def __init__(self, client: OtfClient): + self.client = client + + def gateway_request( + self, method: str, path: str, params: dict[str, Any] | None = None, headers: dict[str, Any] | None = None + ) -> Any: # noqa: ANN401 + """Perform an API request to the Gateway API.""" + return self.client.do(method, API_GATEWAY_BASE_URL, path, params, headers=headers) + + def get_workout_stats(self, stats_key: str, start_date: str, end_date: str) -> dict: + """Retrieve workout stats for a specific metric over a date range. + + Args: + stats_key: The stat key (e.g. 'splat_points', 'average_hr'). + start_date: ISO-format start date. + end_date: ISO-format end date. + """ + return self.gateway_request( + "GET", + f"/consumer-mobile/v1/users/me/workout-stats/{stats_key}", + params={"start": start_date, "end": end_date}, + ) + + def get_workout_stats_preview(self, workout_count: int = 10) -> dict: + """Retrieve a preview of workout stats across all metrics. + + Args: + workout_count: Number of recent workouts to include. Default is 10. + """ + return self.gateway_request( + "GET", + "/consumer-mobile/v1/users/me/workout-stats/preview", + params={"workoutCount": workout_count}, + ) diff --git a/src/otf_api/models/__init__.py b/src/otf_api/models/__init__.py index c22cf168..af95b7f6 100644 --- a/src/otf_api/models/__init__.py +++ b/src/otf_api/models/__init__.py @@ -13,6 +13,14 @@ from .members import MemberDetail, MemberMembership, MemberPurchase from .members.notifications import EmailNotificationSettings, SmsNotificationSettings from .studios import StudioDetail, StudioService, StudioStatus +from .trends import ( + PreviewStat, + StatPoint, + TrendCategory, + TrendType, + WorkoutStatsPreviewResponse, + WorkoutStatsResponse, +) from .workouts import ( BodyCompositionData, ChallengeCategory, @@ -53,7 +61,9 @@ "OutOfStudioWorkoutHistory", "OutStudioStatsData", "PerformanceSummary", + "PreviewStat", "SmsNotificationSettings", + "StatPoint", "StatsResponse", "StatsTime", "StudioDetail", @@ -62,7 +72,11 @@ "Telemetry", "TelemetryHistoryItem", "TimeStats", + "TrendCategory", + "TrendType", "Workout", + "WorkoutStatsPreviewResponse", + "WorkoutStatsResponse", "get_class_rating_value", "get_coach_rating_value", ] diff --git a/src/otf_api/models/trends/__init__.py b/src/otf_api/models/trends/__init__.py new file mode 100644 index 00000000..53edb82c --- /dev/null +++ b/src/otf_api/models/trends/__init__.py @@ -0,0 +1,17 @@ +from .trends import ( + PreviewStat, + StatPoint, + TrendCategory, + TrendType, + WorkoutStatsPreviewResponse, + WorkoutStatsResponse, +) + +__all__ = [ + "PreviewStat", + "StatPoint", + "TrendCategory", + "TrendType", + "WorkoutStatsPreviewResponse", + "WorkoutStatsResponse", +] diff --git a/src/otf_api/models/trends/trends.py b/src/otf_api/models/trends/trends.py new file mode 100644 index 00000000..4df32e45 --- /dev/null +++ b/src/otf_api/models/trends/trends.py @@ -0,0 +1,67 @@ +from datetime import datetime +from enum import StrEnum + +from pydantic import Field + +from otf_api.models.base import OtfItemBase + + +class TrendCategory(StrEnum): + Effort = "effort" + Treadmill = "treadmill" + Rower = "rower" + + +class TrendType(StrEnum): + SplatPoints = "splat_points" + AverageHeartRate = "average_hr" + PeakHeartRate = "peak_hr" + TreadmillTopSpeed = "tread_top_speed" + RowerSplitTime = "rower_500m_split_time" + RowerTopPower = "rower_top_power" + + @property + def category(self) -> TrendCategory: + """Get the category this trend type belongs to.""" + return _TREND_CATEGORY_MAP[self] + + +_TREND_CATEGORY_MAP: dict[TrendType, TrendCategory] = { + TrendType.SplatPoints: TrendCategory.Effort, + TrendType.AverageHeartRate: TrendCategory.Effort, + TrendType.PeakHeartRate: TrendCategory.Effort, + TrendType.TreadmillTopSpeed: TrendCategory.Treadmill, + TrendType.RowerSplitTime: TrendCategory.Rower, + TrendType.RowerTopPower: TrendCategory.Rower, +} + + +class StatPoint(OtfItemBase): + date: datetime | None = None + value: float | None = None + workout_id: str | None = None + class_type: str | None = None + class_name: str | None = None + + +class WorkoutStatsResponse(OtfItemBase): + start_date_time: datetime | None = Field(None, validation_alias="start") + end_date_time: datetime | None = Field(None, validation_alias="end") + stat_key: str | None = None + unit: str | None = None + value_type: str | None = None + points: list[StatPoint] = Field(default_factory=list) + + +class PreviewStat(OtfItemBase): + points: list[StatPoint] = Field(default_factory=list) + stat_key: str | None = None + unit: str | None = None + value_type: str | None = None + + +class WorkoutStatsPreviewResponse(OtfItemBase): + start_date_time: datetime | None = Field(None, validation_alias="start") + end_date_time: datetime | None = Field(None, validation_alias="end") + stats: list[PreviewStat] = Field(default_factory=list) + requested_workout_count: int | None = None From e754ff89c09b04d876950e243464308dba4b43e0 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Thu, 6 Aug 2026 18:51:41 -0500 Subject: [PATCH 2/3] refactor: address clean-code findings --- src/otf_api/api/api.py | 6 ++-- src/otf_api/api/trends/__init__.py | 4 +-- src/otf_api/api/trends/trend_api.py | 13 +++++---- src/otf_api/api/trends/trend_client.py | 2 +- src/otf_api/models/trends/__init__.py | 3 +- src/otf_api/models/trends/enums.py | 35 +++++++++++++++++++++++ src/otf_api/models/trends/trends.py | 39 ++++++-------------------- 7 files changed, 57 insertions(+), 45 deletions(-) create mode 100644 src/otf_api/models/trends/enums.py diff --git a/src/otf_api/api/api.py b/src/otf_api/api/api.py index fa163550..84434e58 100644 --- a/src/otf_api/api/api.py +++ b/src/otf_api/api/api.py @@ -5,7 +5,7 @@ from .client import OtfClient from .members import MemberApi from .studios import StudioApi -from .trends import TrendsApi +from .trends import TrendApi from .workouts import WorkoutApi @@ -25,7 +25,7 @@ class Otf: members: MemberApi workouts: WorkoutApi studios: StudioApi - trends: TrendsApi + trends: TrendApi def __init__(self, user: OtfUser | None = None): """Initialize the OTF API client. @@ -39,7 +39,7 @@ def __init__(self, user: OtfUser | None = None): self.members = MemberApi(self, client) self.workouts = WorkoutApi(self, client) self.studios = StudioApi(self, client) - self.trends = TrendsApi(self, client) + self.trends = TrendApi(self, client) self._member: models.MemberDetail | None = None diff --git a/src/otf_api/api/trends/__init__.py b/src/otf_api/api/trends/__init__.py index 48aa275a..12c725f1 100644 --- a/src/otf_api/api/trends/__init__.py +++ b/src/otf_api/api/trends/__init__.py @@ -1,3 +1,3 @@ -from .trend_api import TrendsApi +from .trend_api import TrendApi -__all__ = ["TrendsApi"] +__all__ = ["TrendApi"] diff --git a/src/otf_api/api/trends/trend_api.py b/src/otf_api/api/trends/trend_api.py index 19b8c180..05f83f0b 100644 --- a/src/otf_api/api/trends/trend_api.py +++ b/src/otf_api/api/trends/trend_api.py @@ -1,6 +1,5 @@ import typing from datetime import date -from logging import getLogger import pendulum @@ -11,16 +10,16 @@ WorkoutStatsResponse, ) -from .trend_client import TrendsClient +from .trend_client import TrendClient if typing.TYPE_CHECKING: from otf_api import Otf from otf_api.api.client import OtfClient -LOGGER = getLogger(__name__) +DEFAULT_PREVIEW_WORKOUT_COUNT = 10 -class TrendsApi: +class TrendApi: """API for retrieving workout trend data from OrangeTheory. Provides methods to get per-metric trend data (splat points, heart rate, @@ -29,7 +28,7 @@ class TrendsApi: def __init__(self, otf: "Otf", otf_client: "OtfClient"): self.otf = otf - self.client = TrendsClient(otf_client) + self.client = TrendClient(otf_client) def get_workout_stats( self, @@ -57,7 +56,9 @@ def get_workout_stats( data = self.client.get_workout_stats(stats_key, start_str, end_str) return WorkoutStatsResponse(**data) - def get_workout_stats_preview(self, workout_count: int = 10) -> WorkoutStatsPreviewResponse: + def get_workout_stats_preview( + self, workout_count: int = DEFAULT_PREVIEW_WORKOUT_COUNT + ) -> WorkoutStatsPreviewResponse: """Get a preview of workout stats across all trend metrics. Args: diff --git a/src/otf_api/api/trends/trend_client.py b/src/otf_api/api/trends/trend_client.py index 01522761..fb1bc091 100644 --- a/src/otf_api/api/trends/trend_client.py +++ b/src/otf_api/api/trends/trend_client.py @@ -3,7 +3,7 @@ from otf_api.api.client import API_GATEWAY_BASE_URL, OtfClient -class TrendsClient: +class TrendClient: """Client for retrieving workout trends/stats data from the OTF Gateway API.""" def __init__(self, client: OtfClient): diff --git a/src/otf_api/models/trends/__init__.py b/src/otf_api/models/trends/__init__.py index 53edb82c..906fdfba 100644 --- a/src/otf_api/models/trends/__init__.py +++ b/src/otf_api/models/trends/__init__.py @@ -1,8 +1,7 @@ +from .enums import TrendCategory, TrendType from .trends import ( PreviewStat, StatPoint, - TrendCategory, - TrendType, WorkoutStatsPreviewResponse, WorkoutStatsResponse, ) diff --git a/src/otf_api/models/trends/enums.py b/src/otf_api/models/trends/enums.py new file mode 100644 index 00000000..a26bacf6 --- /dev/null +++ b/src/otf_api/models/trends/enums.py @@ -0,0 +1,35 @@ +from enum import StrEnum + + +class TrendCategory(StrEnum): + """Categories that group related workout trend metrics.""" + + Effort = "effort" + Treadmill = "treadmill" + Rower = "rower" + + +class TrendType(StrEnum): + """Available workout stat keys for the trends API.""" + + SplatPoints = "splat_points" + AverageHeartRate = "average_hr" + PeakHeartRate = "peak_hr" + TreadmillTopSpeed = "tread_top_speed" + RowerSplitTime = "rower_500m_split_time" + RowerTopPower = "rower_top_power" + + @property + def category(self) -> TrendCategory: + """Get the category this trend type belongs to.""" + return _TREND_CATEGORY_MAP[self] + + +_TREND_CATEGORY_MAP: dict[TrendType, TrendCategory] = { + TrendType.SplatPoints: TrendCategory.Effort, + TrendType.AverageHeartRate: TrendCategory.Effort, + TrendType.PeakHeartRate: TrendCategory.Effort, + TrendType.TreadmillTopSpeed: TrendCategory.Treadmill, + TrendType.RowerSplitTime: TrendCategory.Rower, + TrendType.RowerTopPower: TrendCategory.Rower, +} diff --git a/src/otf_api/models/trends/trends.py b/src/otf_api/models/trends/trends.py index 4df32e45..55ee6ec3 100644 --- a/src/otf_api/models/trends/trends.py +++ b/src/otf_api/models/trends/trends.py @@ -1,42 +1,13 @@ from datetime import datetime -from enum import StrEnum from pydantic import Field from otf_api.models.base import OtfItemBase -class TrendCategory(StrEnum): - Effort = "effort" - Treadmill = "treadmill" - Rower = "rower" - - -class TrendType(StrEnum): - SplatPoints = "splat_points" - AverageHeartRate = "average_hr" - PeakHeartRate = "peak_hr" - TreadmillTopSpeed = "tread_top_speed" - RowerSplitTime = "rower_500m_split_time" - RowerTopPower = "rower_top_power" - - @property - def category(self) -> TrendCategory: - """Get the category this trend type belongs to.""" - return _TREND_CATEGORY_MAP[self] - - -_TREND_CATEGORY_MAP: dict[TrendType, TrendCategory] = { - TrendType.SplatPoints: TrendCategory.Effort, - TrendType.AverageHeartRate: TrendCategory.Effort, - TrendType.PeakHeartRate: TrendCategory.Effort, - TrendType.TreadmillTopSpeed: TrendCategory.Treadmill, - TrendType.RowerSplitTime: TrendCategory.Rower, - TrendType.RowerTopPower: TrendCategory.Rower, -} - - class StatPoint(OtfItemBase): + """A single data point in a workout stats time series.""" + date: datetime | None = None value: float | None = None workout_id: str | None = None @@ -45,6 +16,8 @@ class StatPoint(OtfItemBase): class WorkoutStatsResponse(OtfItemBase): + """Response from the workout stats endpoint for a single metric over a date range.""" + start_date_time: datetime | None = Field(None, validation_alias="start") end_date_time: datetime | None = Field(None, validation_alias="end") stat_key: str | None = None @@ -54,6 +27,8 @@ class WorkoutStatsResponse(OtfItemBase): class PreviewStat(OtfItemBase): + """A single metric's data within a workout stats preview response.""" + points: list[StatPoint] = Field(default_factory=list) stat_key: str | None = None unit: str | None = None @@ -61,6 +36,8 @@ class PreviewStat(OtfItemBase): class WorkoutStatsPreviewResponse(OtfItemBase): + """Response from the workout stats preview endpoint, containing all metrics.""" + start_date_time: datetime | None = Field(None, validation_alias="start") end_date_time: datetime | None = Field(None, validation_alias="end") stats: list[PreviewStat] = Field(default_factory=list) From 4fdf9975365652cb0a9fbe25ed6fb70c31ac96dc Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Thu, 6 Aug 2026 19:01:36 -0500 Subject: [PATCH 3/3] docs: fix class names in CLAUDE.md (TrendsApi -> TrendApi) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index dfaabe51..eddf1ad0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,7 @@ Each domain has a `*Client` (raw HTTP, returns dicts) and a `*Api` (business log - `StudioClient` / `StudioApi` — raw HTTP calls vs typed studio operations - `WorkoutClient` / `WorkoutApi` — raw HTTP calls vs typed workout operations - `MemberClient` / `MemberApi` — raw HTTP calls vs typed member operations -- `TrendsClient` / `TrendsApi` — raw HTTP calls vs typed workout trends/stats operations +- `TrendClient` / `TrendApi` — raw HTTP calls vs typed workout trends/stats operations The `*Client` classes are internal; the `*Api` classes are what users interact with via `Otf.bookings`, `Otf.studios`, `Otf.trends`, etc.