Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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):
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/otf_api/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .client import OtfClient
from .members import MemberApi
from .studios import StudioApi
from .trends import TrendApi
from .workouts import WorkoutApi


Expand All @@ -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.
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/otf_api/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/otf_api/api/trends/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .trend_api import TrendApi

__all__ = ["TrendApi"]
71 changes: 71 additions & 0 deletions src/otf_api/api/trends/trend_api.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 42 additions & 0 deletions src/otf_api/api/trends/trend_client.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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},
)
14 changes: 14 additions & 0 deletions src/otf_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,7 +61,9 @@
"OutOfStudioWorkoutHistory",
"OutStudioStatsData",
"PerformanceSummary",
"PreviewStat",
"SmsNotificationSettings",
"StatPoint",
"StatsResponse",
"StatsTime",
"StudioDetail",
Expand All @@ -62,7 +72,11 @@
"Telemetry",
"TelemetryHistoryItem",
"TimeStats",
"TrendCategory",
"TrendType",
"Workout",
"WorkoutStatsPreviewResponse",
"WorkoutStatsResponse",
"get_class_rating_value",
"get_coach_rating_value",
]
16 changes: 16 additions & 0 deletions src/otf_api/models/trends/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from .enums import TrendCategory, TrendType
from .trends import (
PreviewStat,
StatPoint,
WorkoutStatsPreviewResponse,
WorkoutStatsResponse,
)

__all__ = [
"PreviewStat",
"StatPoint",
"TrendCategory",
"TrendType",
"WorkoutStatsPreviewResponse",
"WorkoutStatsResponse",
]
35 changes: 35 additions & 0 deletions src/otf_api/models/trends/enums.py
Original file line number Diff line number Diff line change
@@ -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,
}
44 changes: 44 additions & 0 deletions src/otf_api/models/trends/trends.py
Original file line number Diff line number Diff line change
@@ -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