@nativesquare/soma 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +260 -19
  2. package/dist/client/index.d.ts +158 -4
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +165 -3
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +2 -0
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +37 -0
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/public.d.ts +3 -3
  12. package/dist/component/schema.d.ts +18 -5
  13. package/dist/component/schema.d.ts.map +1 -1
  14. package/dist/component/schema.js +10 -0
  15. package/dist/component/schema.js.map +1 -1
  16. package/dist/component/strava.d.ts +88 -0
  17. package/dist/component/strava.d.ts.map +1 -0
  18. package/dist/component/strava.js +318 -0
  19. package/dist/component/strava.js.map +1 -0
  20. package/dist/component/validators/activity.d.ts +4 -4
  21. package/dist/component/validators/samples.d.ts +2 -2
  22. package/dist/strava/activity.d.ts +121 -0
  23. package/dist/strava/activity.d.ts.map +1 -0
  24. package/dist/strava/activity.js +201 -0
  25. package/dist/strava/activity.js.map +1 -0
  26. package/dist/strava/athlete.d.ts +34 -0
  27. package/dist/strava/athlete.d.ts.map +1 -0
  28. package/dist/strava/athlete.js +39 -0
  29. package/dist/strava/athlete.js.map +1 -0
  30. package/dist/strava/auth.d.ts +103 -0
  31. package/dist/strava/auth.d.ts.map +1 -0
  32. package/dist/strava/auth.js +111 -0
  33. package/dist/strava/auth.js.map +1 -0
  34. package/dist/strava/client.d.ts +93 -0
  35. package/dist/strava/client.d.ts.map +1 -0
  36. package/dist/strava/client.js +158 -0
  37. package/dist/strava/client.js.map +1 -0
  38. package/dist/strava/index.d.ts +13 -0
  39. package/dist/strava/index.d.ts.map +1 -0
  40. package/dist/strava/index.js +17 -0
  41. package/dist/strava/index.js.map +1 -0
  42. package/dist/strava/maps/sport-type.d.ts +7 -0
  43. package/dist/strava/maps/sport-type.d.ts.map +1 -0
  44. package/dist/strava/maps/sport-type.js +84 -0
  45. package/dist/strava/maps/sport-type.js.map +1 -0
  46. package/dist/strava/sync.d.ts +104 -0
  47. package/dist/strava/sync.d.ts.map +1 -0
  48. package/dist/strava/sync.js +87 -0
  49. package/dist/strava/sync.js.map +1 -0
  50. package/dist/strava/types.d.ts +266 -0
  51. package/dist/strava/types.d.ts.map +1 -0
  52. package/dist/strava/types.js +8 -0
  53. package/dist/strava/types.js.map +1 -0
  54. package/package.json +5 -1
  55. package/src/client/index.ts +212 -4
  56. package/src/component/_generated/api.ts +2 -0
  57. package/src/component/_generated/component.ts +49 -0
  58. package/src/component/schema.ts +11 -0
  59. package/src/component/strava.ts +383 -0
  60. package/src/strava/activity.test.ts +415 -0
  61. package/src/strava/activity.ts +276 -0
  62. package/src/strava/athlete.test.ts +139 -0
  63. package/src/strava/athlete.ts +47 -0
  64. package/src/strava/auth.test.ts +78 -0
  65. package/src/strava/auth.ts +185 -0
  66. package/src/strava/client.ts +212 -0
  67. package/src/strava/index.ts +54 -0
  68. package/src/strava/maps/sport-type.test.ts +69 -0
  69. package/src/strava/maps/sport-type.ts +99 -0
  70. package/src/strava/sync.ts +168 -0
  71. package/src/strava/types.ts +361 -0
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformAthlete } from "./athlete.js";
3
+ import type { DetailedAthlete } from "./types.js";
4
+
5
+ const baseAthlete: DetailedAthlete = {
6
+ id: 10001,
7
+ username: "carlos_v",
8
+ resource_state: 3,
9
+ firstname: "Carlos",
10
+ lastname: "Velasquez",
11
+ city: "Boulder",
12
+ state: "Colorado",
13
+ country: "US",
14
+ sex: "M",
15
+ premium: true,
16
+ summit: true,
17
+ created_at: "2019-03-12T08:15:00Z",
18
+ updated_at: "2025-12-01T14:22:30Z",
19
+ badge_type_id: 6,
20
+ profile_medium:
21
+ "https://dgalywyr863hv.cloudfront.net/pictures/athletes/10001/10001/2/medium.jpg",
22
+ profile:
23
+ "https://dgalywyr863hv.cloudfront.net/pictures/athletes/10001/10001/2/large.jpg",
24
+ friend: null,
25
+ follower: null,
26
+ follower_count: 342,
27
+ friend_count: 128,
28
+ mutual_friend_count: 0,
29
+ athlete_type: 0,
30
+ date_preference: "%m/%d/%Y",
31
+ measurement_preference: "feet",
32
+ ftp: 310,
33
+ weight: 72.5,
34
+ clubs: [
35
+ {
36
+ id: 5001,
37
+ resource_state: 2,
38
+ name: "Boulder Cycling Club",
39
+ profile_medium:
40
+ "https://dgalywyr863hv.cloudfront.net/pictures/clubs/5001/medium.jpg",
41
+ sport_type: "cycling",
42
+ city: "Boulder",
43
+ state: "Colorado",
44
+ country: "US",
45
+ member_count: 215,
46
+ featured: false,
47
+ verified: true,
48
+ url: "boulder-cycling-club",
49
+ },
50
+ ],
51
+ bikes: [
52
+ {
53
+ id: "b1000001",
54
+ primary: true,
55
+ name: "Specialized Tarmac SL7",
56
+ resource_state: 2,
57
+ distance: 12450600,
58
+ },
59
+ {
60
+ id: "b1000002",
61
+ primary: false,
62
+ name: "Canyon Endurace CF",
63
+ resource_state: 2,
64
+ distance: 5820300,
65
+ },
66
+ ],
67
+ shoes: [
68
+ {
69
+ id: "g1000001",
70
+ primary: true,
71
+ name: "Shimano S-Phyre RC9",
72
+ resource_state: 2,
73
+ distance: 8200000,
74
+ },
75
+ ],
76
+ };
77
+
78
+ describe("transformAthlete", () => {
79
+ it("maps first and last name", () => {
80
+ const result = transformAthlete(baseAthlete);
81
+ expect(result.first_name).toBe("Carlos");
82
+ expect(result.last_name).toBe("Velasquez");
83
+ });
84
+
85
+ it("maps location fields", () => {
86
+ const result = transformAthlete(baseAthlete);
87
+ expect(result.city).toBe("Boulder");
88
+ expect(result.state).toBe("Colorado");
89
+ expect(result.country).toBe("US");
90
+ });
91
+
92
+ it("maps sex M to male", () => {
93
+ const result = transformAthlete(baseAthlete);
94
+ expect(result.sex).toBe("male");
95
+ });
96
+
97
+ it("maps sex F to female", () => {
98
+ const female = { ...baseAthlete, sex: "F" as const };
99
+ const result = transformAthlete(female);
100
+ expect(result.sex).toBe("female");
101
+ });
102
+
103
+ it("maps null sex to undefined", () => {
104
+ const noSex = { ...baseAthlete, sex: null };
105
+ const result = transformAthlete(noSex);
106
+ expect(result.sex).toBeUndefined();
107
+ });
108
+
109
+ it("maps created_at to joined_provider", () => {
110
+ const result = transformAthlete(baseAthlete);
111
+ expect(result.joined_provider).toBe("2019-03-12T08:15:00Z");
112
+ });
113
+
114
+ it("maps bikes and shoes to devices", () => {
115
+ const result = transformAthlete(baseAthlete);
116
+ expect(result.devices).toHaveLength(3);
117
+ expect(result.devices?.[0]).toEqual({
118
+ name: "Specialized Tarmac SL7",
119
+ id: "b1000001",
120
+ });
121
+ expect(result.devices?.[2]).toEqual({
122
+ name: "Shimano S-Phyre RC9",
123
+ id: "g1000001",
124
+ });
125
+ });
126
+
127
+ it("handles athlete with null location", () => {
128
+ const noLocation = {
129
+ ...baseAthlete,
130
+ city: null,
131
+ state: null,
132
+ country: null,
133
+ };
134
+ const result = transformAthlete(noLocation);
135
+ expect(result.city).toBeUndefined();
136
+ expect(result.state).toBeUndefined();
137
+ expect(result.country).toBeUndefined();
138
+ });
139
+ });
@@ -0,0 +1,47 @@
1
+ // ─── Athlete Transformer ─────────────────────────────────────────────────────
2
+ // Transforms a Strava DetailedAthlete into the Soma Athlete schema shape.
3
+
4
+ import type { DetailedAthlete } from "./types.js";
5
+
6
+ /**
7
+ * The output shape of {@link transformAthlete}.
8
+ */
9
+ export type AthleteData = ReturnType<typeof transformAthlete>;
10
+
11
+ /**
12
+ * Transform a Strava athlete profile into a Soma Athlete document shape.
13
+ *
14
+ * Strava provides a relatively rich profile compared to HealthKit, including
15
+ * name, location, sex, and the date the athlete joined Strava.
16
+ *
17
+ * @param athlete - The Strava DetailedAthlete from `GET /athlete`
18
+ * @returns Soma Athlete fields (without connectionId/userId)
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * const data = transformAthlete(stravaAthlete);
23
+ * await soma.ingestAthlete(ctx, { connectionId, userId, ...data });
24
+ * ```
25
+ */
26
+ export function transformAthlete(athlete: DetailedAthlete) {
27
+ const sexMap: Record<string, string> = {
28
+ M: "male",
29
+ F: "female",
30
+ };
31
+
32
+ return {
33
+ first_name: athlete.firstname ?? undefined,
34
+ last_name: athlete.lastname ?? undefined,
35
+ city: athlete.city ?? undefined,
36
+ state: athlete.state ?? undefined,
37
+ country: athlete.country ?? undefined,
38
+ sex: athlete.sex ? sexMap[athlete.sex] : undefined,
39
+ joined_provider: athlete.created_at ?? undefined,
40
+ devices: athlete.bikes && athlete.shoes
41
+ ? [
42
+ ...athlete.bikes.map((b) => ({ name: b.name, id: b.id })),
43
+ ...athlete.shoes.map((s) => ({ name: s.name, id: s.id })),
44
+ ]
45
+ : undefined,
46
+ };
47
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildAuthUrl } from "./auth.js";
3
+
4
+ describe("buildAuthUrl", () => {
5
+ it("builds a valid Strava authorization URL", () => {
6
+ const url = buildAuthUrl({
7
+ clientId: "12345",
8
+ redirectUri: "https://example.com/callback",
9
+ });
10
+
11
+ const parsed = new URL(url);
12
+ expect(parsed.origin).toBe("https://www.strava.com");
13
+ expect(parsed.pathname).toBe("/oauth/authorize");
14
+ expect(parsed.searchParams.get("client_id")).toBe("12345");
15
+ expect(parsed.searchParams.get("redirect_uri")).toBe(
16
+ "https://example.com/callback",
17
+ );
18
+ expect(parsed.searchParams.get("response_type")).toBe("code");
19
+ expect(parsed.searchParams.get("approval_prompt")).toBe("auto");
20
+ expect(parsed.searchParams.get("scope")).toBe(
21
+ "read,activity:read_all,profile:read_all",
22
+ );
23
+ });
24
+
25
+ it("uses custom scope", () => {
26
+ const url = buildAuthUrl({
27
+ clientId: "12345",
28
+ redirectUri: "https://example.com/callback",
29
+ scope: "read,activity:read",
30
+ });
31
+
32
+ const parsed = new URL(url);
33
+ expect(parsed.searchParams.get("scope")).toBe("read,activity:read");
34
+ });
35
+
36
+ it("includes state parameter when provided", () => {
37
+ const url = buildAuthUrl({
38
+ clientId: "12345",
39
+ redirectUri: "https://example.com/callback",
40
+ state: "csrf-token-123",
41
+ });
42
+
43
+ const parsed = new URL(url);
44
+ expect(parsed.searchParams.get("state")).toBe("csrf-token-123");
45
+ });
46
+
47
+ it("omits state parameter when not provided", () => {
48
+ const url = buildAuthUrl({
49
+ clientId: "12345",
50
+ redirectUri: "https://example.com/callback",
51
+ });
52
+
53
+ const parsed = new URL(url);
54
+ expect(parsed.searchParams.has("state")).toBe(false);
55
+ });
56
+
57
+ it("uses custom baseUrl", () => {
58
+ const url = buildAuthUrl({
59
+ clientId: "12345",
60
+ redirectUri: "https://example.com/callback",
61
+ baseUrl: "https://strava-mock-server.onrender.com",
62
+ });
63
+
64
+ const parsed = new URL(url);
65
+ expect(parsed.origin).toBe("https://strava-mock-server.onrender.com");
66
+ });
67
+
68
+ it("strips trailing slashes from baseUrl", () => {
69
+ const url = buildAuthUrl({
70
+ clientId: "12345",
71
+ redirectUri: "https://example.com/callback",
72
+ baseUrl: "https://mock.example.com///",
73
+ });
74
+
75
+ expect(url).toMatch(/^https:\/\/mock\.example\.com\/oauth/);
76
+ expect(url).not.toMatch(/\/\/oauth/);
77
+ });
78
+ });
@@ -0,0 +1,185 @@
1
+ // ─── Strava OAuth Helpers ────────────────────────────────────────────────────
2
+ // Pure helper functions for the Strava OAuth 2.0 Authorization Code flow.
3
+ // No external dependencies — uses the global `fetch`.
4
+
5
+ import type { OAuthTokenResponse } from "./types.js";
6
+
7
+ const DEFAULT_BASE_URL = "https://www.strava.com";
8
+
9
+ // ─── Build Authorization URL ─────────────────────────────────────────────────
10
+
11
+ export interface BuildAuthUrlOptions {
12
+ /** Your Strava application's Client ID. */
13
+ clientId: string;
14
+ /** The URL Strava will redirect to after authorization. */
15
+ redirectUri: string;
16
+ /**
17
+ * Comma-separated Strava OAuth scopes.
18
+ * @default "read,activity:read_all,profile:read_all"
19
+ */
20
+ scope?: string;
21
+ /** Optional state parameter for CSRF protection. */
22
+ state?: string;
23
+ /**
24
+ * Base URL of the Strava site.
25
+ * @default "https://www.strava.com"
26
+ */
27
+ baseUrl?: string;
28
+ }
29
+
30
+ /**
31
+ * Build the Strava OAuth authorization URL.
32
+ *
33
+ * Redirect the user to this URL to begin the OAuth flow. After the user
34
+ * grants access, Strava will redirect back to `redirectUri` with a `code`
35
+ * query parameter.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const url = buildAuthUrl({
40
+ * clientId: process.env.STRAVA_CLIENT_ID!,
41
+ * redirectUri: "https://your-app.com/api/strava/callback",
42
+ * });
43
+ * // Redirect user to `url`
44
+ * ```
45
+ */
46
+ export function buildAuthUrl(opts: BuildAuthUrlOptions): string {
47
+ const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
48
+ const params = new URLSearchParams({
49
+ client_id: opts.clientId,
50
+ redirect_uri: opts.redirectUri,
51
+ response_type: "code",
52
+ approval_prompt: "auto",
53
+ scope: opts.scope ?? "read,activity:read_all,profile:read_all",
54
+ });
55
+
56
+ if (opts.state) {
57
+ params.set("state", opts.state);
58
+ }
59
+
60
+ return `${base}/oauth/authorize?${params.toString()}`;
61
+ }
62
+
63
+ // ─── Exchange Authorization Code ─────────────────────────────────────────────
64
+
65
+ export interface ExchangeCodeOptions {
66
+ /** Your Strava application's Client ID. */
67
+ clientId: string;
68
+ /** Your Strava application's Client Secret. */
69
+ clientSecret: string;
70
+ /** The authorization code from the OAuth callback. */
71
+ code: string;
72
+ /**
73
+ * Base URL of the Strava site.
74
+ * @default "https://www.strava.com"
75
+ */
76
+ baseUrl?: string;
77
+ }
78
+
79
+ /**
80
+ * Exchange an authorization code for access and refresh tokens.
81
+ *
82
+ * Call this from your OAuth callback endpoint after receiving the `code`
83
+ * query parameter from Strava.
84
+ *
85
+ * @returns The token response including `access_token`, `refresh_token`,
86
+ * `expires_at`, and the authenticated `athlete` profile.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * const tokens = await exchangeCode({
91
+ * clientId: process.env.STRAVA_CLIENT_ID!,
92
+ * clientSecret: process.env.STRAVA_CLIENT_SECRET!,
93
+ * code: request.query.code,
94
+ * });
95
+ * // Store tokens.access_token, tokens.refresh_token, tokens.expires_at
96
+ * ```
97
+ */
98
+ export async function exchangeCode(
99
+ opts: ExchangeCodeOptions,
100
+ ): Promise<OAuthTokenResponse> {
101
+ const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
102
+ const url = `${base}/oauth/token`;
103
+
104
+ const response = await fetch(url, {
105
+ method: "POST",
106
+ headers: { "Content-Type": "application/json" },
107
+ body: JSON.stringify({
108
+ client_id: opts.clientId,
109
+ client_secret: opts.clientSecret,
110
+ code: opts.code,
111
+ grant_type: "authorization_code",
112
+ }),
113
+ });
114
+
115
+ if (!response.ok) {
116
+ const body = await response.text().catch(() => "");
117
+ throw new Error(
118
+ `Strava OAuth error (exchangeCode): ${response.status} ${response.statusText} — ${body}`,
119
+ );
120
+ }
121
+
122
+ return (await response.json()) as OAuthTokenResponse;
123
+ }
124
+
125
+ // ─── Refresh Token ───────────────────────────────────────────────────────────
126
+
127
+ export interface RefreshTokenOptions {
128
+ /** Your Strava application's Client ID. */
129
+ clientId: string;
130
+ /** Your Strava application's Client Secret. */
131
+ clientSecret: string;
132
+ /** The refresh token from a previous token exchange or refresh. */
133
+ refreshToken: string;
134
+ /**
135
+ * Base URL of the Strava site.
136
+ * @default "https://www.strava.com"
137
+ */
138
+ baseUrl?: string;
139
+ }
140
+
141
+ /**
142
+ * Refresh an expired access token using a refresh token.
143
+ *
144
+ * Strava access tokens expire after ~6 hours. Call this when the
145
+ * `expires_at` timestamp has passed to obtain a fresh access token.
146
+ *
147
+ * @returns A new token response with a fresh `access_token` and
148
+ * possibly a new `refresh_token`.
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * const tokens = await refreshToken({
153
+ * clientId: process.env.STRAVA_CLIENT_ID!,
154
+ * clientSecret: process.env.STRAVA_CLIENT_SECRET!,
155
+ * refreshToken: storedRefreshToken,
156
+ * });
157
+ * // Update stored tokens
158
+ * ```
159
+ */
160
+ export async function refreshToken(
161
+ opts: RefreshTokenOptions,
162
+ ): Promise<OAuthTokenResponse> {
163
+ const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
164
+ const url = `${base}/oauth/token`;
165
+
166
+ const response = await fetch(url, {
167
+ method: "POST",
168
+ headers: { "Content-Type": "application/json" },
169
+ body: JSON.stringify({
170
+ client_id: opts.clientId,
171
+ client_secret: opts.clientSecret,
172
+ refresh_token: opts.refreshToken,
173
+ grant_type: "refresh_token",
174
+ }),
175
+ });
176
+
177
+ if (!response.ok) {
178
+ const body = await response.text().catch(() => "");
179
+ throw new Error(
180
+ `Strava OAuth error (refreshToken): ${response.status} ${response.statusText} — ${body}`,
181
+ );
182
+ }
183
+
184
+ return (await response.json()) as OAuthTokenResponse;
185
+ }
@@ -0,0 +1,212 @@
1
+ // ─── Strava API Client ───────────────────────────────────────────────────────
2
+ // Lightweight, fetch-based client for the Strava API v3.
3
+ // No external dependencies — uses the global `fetch` available in Convex
4
+ // actions and modern runtimes.
5
+
6
+ import type {
7
+ DetailedActivity,
8
+ DetailedAthlete,
9
+ Lap,
10
+ StreamSet,
11
+ Stream,
12
+ SummaryActivity,
13
+ } from "./types.js";
14
+
15
+ const DEFAULT_BASE_URL = "https://www.strava.com";
16
+ const API_PREFIX = "/api/v3";
17
+
18
+ export interface StravaClientOptions {
19
+ /** OAuth access token for the authenticated athlete. */
20
+ accessToken: string;
21
+ /**
22
+ * Base URL of the Strava API (without `/api/v3` suffix).
23
+ * Defaults to `https://www.strava.com`.
24
+ * Override to point at a mock server during development.
25
+ */
26
+ baseUrl?: string;
27
+ }
28
+
29
+ /**
30
+ * A lightweight client for the Strava API v3.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * const client = new StravaClient({
35
+ * accessToken: "tok_xxx",
36
+ * baseUrl: "https://strava-mock-server.onrender.com", // optional
37
+ * });
38
+ *
39
+ * const athlete = await client.getAthlete();
40
+ * const activities = await client.listActivities({ per_page: 50 });
41
+ * ```
42
+ */
43
+ export class StravaClient {
44
+ private readonly accessToken: string;
45
+ private readonly baseUrl: string;
46
+
47
+ constructor(opts: StravaClientOptions) {
48
+ this.accessToken = opts.accessToken;
49
+ this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
50
+ }
51
+
52
+ // ─── Athlete ─────────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Get the authenticated athlete's profile.
56
+ *
57
+ * Strava API: `GET /athlete`
58
+ */
59
+ async getAthlete(): Promise<DetailedAthlete> {
60
+ return this.get<DetailedAthlete>("/athlete");
61
+ }
62
+
63
+ // ─── Activities ──────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * List the authenticated athlete's activities.
67
+ *
68
+ * Strava API: `GET /athlete/activities`
69
+ *
70
+ * @param params.before - Only return activities before this Unix epoch timestamp
71
+ * @param params.after - Only return activities after this Unix epoch timestamp
72
+ * @param params.page - Page number (defaults to 1)
73
+ * @param params.per_page - Items per page (defaults to 30, max 200)
74
+ */
75
+ async listActivities(params?: {
76
+ before?: number;
77
+ after?: number;
78
+ page?: number;
79
+ per_page?: number;
80
+ }): Promise<SummaryActivity[]> {
81
+ const query = new URLSearchParams();
82
+ if (params?.before != null) query.set("before", String(params.before));
83
+ if (params?.after != null) query.set("after", String(params.after));
84
+ if (params?.page != null) query.set("page", String(params.page));
85
+ if (params?.per_page != null) query.set("per_page", String(params.per_page));
86
+
87
+ const qs = query.toString();
88
+ return this.get<SummaryActivity[]>(
89
+ `/athlete/activities${qs ? `?${qs}` : ""}`,
90
+ );
91
+ }
92
+
93
+ /**
94
+ * List ALL activities for the authenticated athlete, automatically
95
+ * paginating through all pages.
96
+ *
97
+ * @param params.after - Only return activities after this Unix epoch timestamp
98
+ * @param params.before - Only return activities before this Unix epoch timestamp
99
+ * @param params.per_page - Items per page (defaults to 200)
100
+ */
101
+ async listAllActivities(params?: {
102
+ after?: number;
103
+ before?: number;
104
+ per_page?: number;
105
+ }): Promise<SummaryActivity[]> {
106
+ const perPage = params?.per_page ?? 200;
107
+ const all: SummaryActivity[] = [];
108
+ let page = 1;
109
+
110
+ while (true) {
111
+ const batch = await this.listActivities({
112
+ after: params?.after,
113
+ before: params?.before,
114
+ page,
115
+ per_page: perPage,
116
+ });
117
+ all.push(...batch);
118
+ if (batch.length < perPage) break;
119
+ page++;
120
+ }
121
+
122
+ return all;
123
+ }
124
+
125
+ /**
126
+ * Get a detailed activity by ID.
127
+ *
128
+ * Strava API: `GET /activities/{id}`
129
+ */
130
+ async getActivity(id: number): Promise<DetailedActivity> {
131
+ return this.get<DetailedActivity>(`/activities/${id}`);
132
+ }
133
+
134
+ /**
135
+ * Get time-series streams for an activity.
136
+ *
137
+ * Strava API: `GET /activities/{id}/streams`
138
+ *
139
+ * @param id - Activity ID
140
+ * @param keys - Stream types to request (e.g. `["heartrate", "watts", "latlng", "altitude", "time"]`)
141
+ */
142
+ async getActivityStreams(
143
+ id: number,
144
+ keys: string[] = [
145
+ "time",
146
+ "heartrate",
147
+ "watts",
148
+ "cadence",
149
+ "latlng",
150
+ "altitude",
151
+ "velocity_smooth",
152
+ "grade_smooth",
153
+ "distance",
154
+ "temp",
155
+ ],
156
+ ): Promise<StreamSet> {
157
+ const query = new URLSearchParams({
158
+ keys: keys.join(","),
159
+ key_by_type: "true",
160
+ });
161
+ const streams = await this.get<Record<string, Stream>>(
162
+ `/activities/${id}/streams?${query.toString()}`,
163
+ );
164
+ return streams as StreamSet;
165
+ }
166
+
167
+ /**
168
+ * Get laps for an activity.
169
+ *
170
+ * Strava API: `GET /activities/{id}/laps`
171
+ */
172
+ async getActivityLaps(id: number): Promise<Lap[]> {
173
+ return this.get<Lap[]>(`/activities/${id}/laps`);
174
+ }
175
+
176
+ // ─── Internal ────────────────────────────────────────────────────────────
177
+
178
+ private async get<T>(path: string): Promise<T> {
179
+ const url = `${this.baseUrl}${API_PREFIX}${path}`;
180
+ const response = await fetch(url, {
181
+ method: "GET",
182
+ headers: {
183
+ Authorization: `Bearer ${this.accessToken}`,
184
+ Accept: "application/json",
185
+ },
186
+ });
187
+
188
+ if (!response.ok) {
189
+ const body = await response.text().catch(() => "");
190
+ throw new StravaApiError(
191
+ `Strava API error: ${response.status} ${response.statusText}`,
192
+ response.status,
193
+ body,
194
+ );
195
+ }
196
+
197
+ return (await response.json()) as T;
198
+ }
199
+ }
200
+
201
+ // ─── Error ───────────────────────────────────────────────────────────────────
202
+
203
+ export class StravaApiError extends Error {
204
+ constructor(
205
+ message: string,
206
+ public readonly status: number,
207
+ public readonly body: string,
208
+ ) {
209
+ super(message);
210
+ this.name = "StravaApiError";
211
+ }
212
+ }