diff --git a/CLAUDE.md b/CLAUDE.md index e598a554..eddf1ad0 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 +- `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`, 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..84434e58 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 TrendApi from .workouts import WorkoutApi @@ -24,6 +25,7 @@ class Otf: members: MemberApi workouts: WorkoutApi studios: StudioApi + trends: TrendApi 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 = TrendApi(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..12c725f1 --- /dev/null +++ b/src/otf_api/api/trends/__init__.py @@ -0,0 +1,3 @@ +from .trend_api import TrendApi + +__all__ = ["TrendApi"] 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..05f83f0b --- /dev/null +++ b/src/otf_api/api/trends/trend_api.py @@ -0,0 +1,71 @@ +import typing +from datetime import date + +import pendulum + +from otf_api.api import utils +from otf_api.models.trends import ( + TrendType, + WorkoutStatsPreviewResponse, + WorkoutStatsResponse, +) + +from .trend_client import TrendClient + +if typing.TYPE_CHECKING: + from otf_api import Otf + from otf_api.api.client import OtfClient + +DEFAULT_PREVIEW_WORKOUT_COUNT = 10 + + +class TrendApi: + """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 = TrendClient(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 = DEFAULT_PREVIEW_WORKOUT_COUNT + ) -> 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..fb1bc091 --- /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 TrendClient: + """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..906fdfba --- /dev/null +++ b/src/otf_api/models/trends/__init__.py @@ -0,0 +1,16 @@ +from .enums import TrendCategory, TrendType +from .trends import ( + PreviewStat, + StatPoint, + WorkoutStatsPreviewResponse, + WorkoutStatsResponse, +) + +__all__ = [ + "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 new file mode 100644 index 00000000..55ee6ec3 --- /dev/null +++ b/src/otf_api/models/trends/trends.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from pydantic import Field + +from otf_api.models.base import OtfItemBase + + +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 + class_type: str | None = None + class_name: str | None = None + + +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 + unit: str | None = None + value_type: str | None = None + points: list[StatPoint] = Field(default_factory=list) + + +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 + value_type: str | None = None + + +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) + requested_workout_count: int | None = None