@clipin/convex-wearables 0.0.2 → 0.0.3

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 (88) hide show
  1. package/dist/client/index.d.ts +9 -4
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js.map +1 -1
  4. package/dist/component/_generated/component.d.ts +50 -0
  5. package/dist/component/_generated/component.d.ts.map +1 -0
  6. package/dist/component/_generated/component.js +11 -0
  7. package/dist/component/_generated/component.js.map +1 -0
  8. package/dist/component/backfillJobs.d.ts +11 -11
  9. package/dist/component/connections.d.ts +9 -9
  10. package/dist/component/connections.d.ts.map +1 -1
  11. package/dist/component/connections.js +2 -0
  12. package/dist/component/connections.js.map +1 -1
  13. package/dist/component/dataPoints.d.ts +5 -5
  14. package/dist/component/events.d.ts +13 -13
  15. package/dist/component/garminBackfill.d.ts +2 -2
  16. package/dist/component/garminWebhooks.d.ts +2 -2
  17. package/dist/component/garminWebhooks.d.ts.map +1 -1
  18. package/dist/component/garminWebhooks.js +2 -0
  19. package/dist/component/garminWebhooks.js.map +1 -1
  20. package/dist/component/lifecycle.d.ts +1 -1
  21. package/dist/component/lifecycle.d.ts.map +1 -1
  22. package/dist/component/lifecycle.js +2 -0
  23. package/dist/component/lifecycle.js.map +1 -1
  24. package/dist/component/oauthStates.d.ts +3 -3
  25. package/dist/component/schema.d.ts +26 -26
  26. package/dist/component/sdkPush.d.ts +11 -11
  27. package/dist/component/summaries.d.ts +4 -4
  28. package/dist/component/syncJobs.d.ts +23 -23
  29. package/dist/component/syncWorkflow.d.ts +2 -2
  30. package/dist/test.d.ts +421 -0
  31. package/dist/test.d.ts.map +1 -0
  32. package/dist/test.js +17 -0
  33. package/dist/test.js.map +1 -0
  34. package/package.json +12 -2
  35. package/src/client/_generated/_ignore.ts +2 -0
  36. package/src/client/index.test.ts +52 -0
  37. package/src/client/index.ts +784 -0
  38. package/src/client/types.ts +533 -0
  39. package/src/component/_generated/_ignore.ts +2 -0
  40. package/src/component/_generated/api.ts +16 -0
  41. package/src/component/_generated/component.ts +74 -0
  42. package/src/component/_generated/dataModel.ts +40 -0
  43. package/src/component/_generated/server.ts +48 -0
  44. package/src/component/backfillJobs.test.ts +47 -0
  45. package/src/component/backfillJobs.ts +245 -0
  46. package/src/component/connections.test.ts +297 -0
  47. package/src/component/connections.ts +329 -0
  48. package/src/component/convex.config.ts +7 -0
  49. package/src/component/dataPoints.test.ts +282 -0
  50. package/src/component/dataPoints.ts +305 -0
  51. package/src/component/dataSources.test.ts +247 -0
  52. package/src/component/dataSources.ts +109 -0
  53. package/src/component/events.test.ts +380 -0
  54. package/src/component/events.ts +288 -0
  55. package/src/component/garminBackfill.ts +343 -0
  56. package/src/component/garminWebhooks.test.ts +609 -0
  57. package/src/component/garminWebhooks.ts +656 -0
  58. package/src/component/httpHandlers.ts +153 -0
  59. package/src/component/lifecycle.test.ts +179 -0
  60. package/src/component/lifecycle.ts +87 -0
  61. package/src/component/menstrualCycles.ts +124 -0
  62. package/src/component/oauthActions.ts +261 -0
  63. package/src/component/oauthStates.test.ts +170 -0
  64. package/src/component/oauthStates.ts +85 -0
  65. package/src/component/providerSettings.ts +66 -0
  66. package/src/component/providers/additionalProviders.test.ts +401 -0
  67. package/src/component/providers/garmin.ts +1169 -0
  68. package/src/component/providers/oauth.test.ts +174 -0
  69. package/src/component/providers/oauth.ts +246 -0
  70. package/src/component/providers/polar.ts +220 -0
  71. package/src/component/providers/registry.ts +37 -0
  72. package/src/component/providers/strava.test.ts +195 -0
  73. package/src/component/providers/strava.ts +253 -0
  74. package/src/component/providers/suunto.ts +592 -0
  75. package/src/component/providers/types.ts +189 -0
  76. package/src/component/providers/whoop.ts +600 -0
  77. package/src/component/schema.ts +339 -0
  78. package/src/component/sdkPush.test.ts +367 -0
  79. package/src/component/sdkPush.ts +440 -0
  80. package/src/component/summaries.test.ts +201 -0
  81. package/src/component/summaries.ts +143 -0
  82. package/src/component/syncJobs.test.ts +254 -0
  83. package/src/component/syncJobs.ts +140 -0
  84. package/src/component/syncWorkflow.test.ts +87 -0
  85. package/src/component/syncWorkflow.ts +739 -0
  86. package/src/component/test.setup.ts +6 -0
  87. package/src/component/workflowManager.ts +19 -0
  88. package/src/test.ts +25 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Tests for OAuth utility pure functions.
3
+ *
4
+ * No network calls — tests URL building, PKCE generation, and config handling.
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+ import { buildAuthorizationUrl, generateCodeChallenge, generateRandomString } from "./oauth";
9
+ import type { OAuthProviderConfig } from "./types";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function makeConfig(overrides: Partial<OAuthProviderConfig> = {}): OAuthProviderConfig {
16
+ return {
17
+ endpoints: {
18
+ authorizeUrl: "https://provider.example.com/oauth/authorize",
19
+ tokenUrl: "https://provider.example.com/oauth/token",
20
+ apiBaseUrl: "https://api.provider.example.com",
21
+ },
22
+ clientId: "test-client-id",
23
+ clientSecret: "test-client-secret",
24
+ defaultScope: "read write",
25
+ usePkce: false,
26
+ authMethod: "body",
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Tests
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe("generateRandomString", () => {
36
+ it("generates a string of the requested length", () => {
37
+ const s = generateRandomString(32);
38
+ expect(s).toHaveLength(32);
39
+ });
40
+
41
+ it("generates different strings each time", () => {
42
+ const a = generateRandomString(32);
43
+ const b = generateRandomString(32);
44
+ expect(a).not.toBe(b);
45
+ });
46
+
47
+ it("only contains URL-safe characters", () => {
48
+ const s = generateRandomString(100);
49
+ expect(s).toMatch(/^[A-Za-z0-9\-._~]+$/);
50
+ });
51
+ });
52
+
53
+ describe("generateCodeChallenge", () => {
54
+ it("produces a base64url-encoded string", async () => {
55
+ const challenge = await generateCodeChallenge("test-verifier");
56
+ // Base64url: no +, /, or = padding
57
+ expect(challenge).not.toMatch(/[+/=]/);
58
+ expect(challenge.length).toBeGreaterThan(0);
59
+ });
60
+
61
+ it("produces consistent output for same input", async () => {
62
+ const a = await generateCodeChallenge("same-verifier");
63
+ const b = await generateCodeChallenge("same-verifier");
64
+ expect(a).toBe(b);
65
+ });
66
+
67
+ it("produces different output for different input", async () => {
68
+ const a = await generateCodeChallenge("verifier-1");
69
+ const b = await generateCodeChallenge("verifier-2");
70
+ expect(a).not.toBe(b);
71
+ });
72
+ });
73
+
74
+ describe("buildAuthorizationUrl", () => {
75
+ it("builds a basic authorization URL with required params", () => {
76
+ const config = makeConfig();
77
+ const url = buildAuthorizationUrl({
78
+ config,
79
+ redirectUri: "https://myapp.com/callback",
80
+ state: "random-state-123",
81
+ });
82
+
83
+ const parsed = new URL(url);
84
+ expect(parsed.origin).toBe("https://provider.example.com");
85
+ expect(parsed.pathname).toBe("/oauth/authorize");
86
+ expect(parsed.searchParams.get("client_id")).toBe("test-client-id");
87
+ expect(parsed.searchParams.get("redirect_uri")).toBe("https://myapp.com/callback");
88
+ expect(parsed.searchParams.get("response_type")).toBe("code");
89
+ expect(parsed.searchParams.get("scope")).toBe("read write");
90
+ expect(parsed.searchParams.get("state")).toBe("random-state-123");
91
+ });
92
+
93
+ it("includes PKCE code_challenge when enabled", () => {
94
+ const config = makeConfig({ usePkce: true });
95
+ const url = buildAuthorizationUrl({
96
+ config,
97
+ redirectUri: "https://myapp.com/callback",
98
+ state: "state-abc",
99
+ codeChallenge: "challenge-xyz",
100
+ });
101
+
102
+ const parsed = new URL(url);
103
+ expect(parsed.searchParams.get("code_challenge")).toBe("challenge-xyz");
104
+ expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
105
+ });
106
+
107
+ it("omits PKCE params when not enabled", () => {
108
+ const config = makeConfig({ usePkce: false });
109
+ const url = buildAuthorizationUrl({
110
+ config,
111
+ redirectUri: "https://myapp.com/callback",
112
+ state: "state-abc",
113
+ codeChallenge: "challenge-xyz",
114
+ });
115
+
116
+ const parsed = new URL(url);
117
+ expect(parsed.searchParams.get("code_challenge")).toBeNull();
118
+ expect(parsed.searchParams.get("code_challenge_method")).toBeNull();
119
+ });
120
+
121
+ it("includes extra authorize params", () => {
122
+ const config = makeConfig({
123
+ extraAuthorizeParams: {
124
+ "Ocp-Apim-Subscription-Key": "suunto-key-123",
125
+ prompt: "consent",
126
+ },
127
+ });
128
+
129
+ const url = buildAuthorizationUrl({
130
+ config,
131
+ redirectUri: "https://myapp.com/callback",
132
+ state: "state-abc",
133
+ });
134
+
135
+ const parsed = new URL(url);
136
+ expect(parsed.searchParams.get("Ocp-Apim-Subscription-Key")).toBe("suunto-key-123");
137
+ expect(parsed.searchParams.get("prompt")).toBe("consent");
138
+ });
139
+ });
140
+
141
+ describe("provider-specific OAuth configs", () => {
142
+ it("Strava config has correct endpoints", async () => {
143
+ const { stravaOAuthConfig } = await import("./strava");
144
+ const config = stravaOAuthConfig({
145
+ clientId: "my-client-id",
146
+ clientSecret: "my-secret",
147
+ });
148
+
149
+ expect(config.endpoints.authorizeUrl).toBe("https://www.strava.com/oauth/authorize");
150
+ expect(config.endpoints.tokenUrl).toBe("https://www.strava.com/api/v3/oauth/token");
151
+ expect(config.clientId).toBe("my-client-id");
152
+ expect(config.clientSecret).toBe("my-secret");
153
+ expect(config.usePkce).toBe(false);
154
+ expect(config.authMethod).toBe("body");
155
+ expect(config.defaultScope).toContain("activity:read_all");
156
+ });
157
+
158
+ it("Strava auth URL includes correct scope", async () => {
159
+ const { stravaOAuthConfig } = await import("./strava");
160
+ const config = stravaOAuthConfig({
161
+ clientId: "cid",
162
+ clientSecret: "csec",
163
+ });
164
+
165
+ const url = buildAuthorizationUrl({
166
+ config,
167
+ redirectUri: "https://app.com/cb",
168
+ state: "st",
169
+ });
170
+
171
+ const parsed = new URL(url);
172
+ expect(parsed.searchParams.get("scope")).toBe("activity:read_all,profile:read_all");
173
+ });
174
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Generic OAuth 2.0 utilities.
3
+ * Used by provider-specific OAuth configs to build authorization URLs,
4
+ * exchange codes for tokens, and refresh tokens.
5
+ */
6
+
7
+ import type { OAuthProviderConfig, OAuthTokenResponse } from "./types";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // PKCE helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Generate a random URL-safe string for use as state or code_verifier.
15
+ */
16
+ export function generateRandomString(length: number): string {
17
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
18
+ const array = new Uint8Array(length);
19
+ crypto.getRandomValues(array);
20
+ return Array.from(array, (b) => chars[b % chars.length]).join("");
21
+ }
22
+
23
+ /**
24
+ * Generate a PKCE code_challenge from a code_verifier using SHA-256.
25
+ */
26
+ export async function generateCodeChallenge(codeVerifier: string): Promise<string> {
27
+ const encoder = new TextEncoder();
28
+ const data = encoder.encode(codeVerifier);
29
+ const digest = await crypto.subtle.digest("SHA-256", data);
30
+ // Base64url encode (no padding)
31
+ const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)));
32
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Authorization URL
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export interface AuthUrlParams {
40
+ config: OAuthProviderConfig;
41
+ redirectUri: string;
42
+ state: string;
43
+ codeChallenge?: string;
44
+ }
45
+
46
+ /**
47
+ * Build the provider's authorization URL.
48
+ */
49
+ export function buildAuthorizationUrl(params: AuthUrlParams): string {
50
+ const { config, redirectUri, state, codeChallenge } = params;
51
+ const url = new URL(config.endpoints.authorizeUrl);
52
+
53
+ url.searchParams.set("client_id", config.clientId);
54
+ url.searchParams.set("redirect_uri", redirectUri);
55
+ url.searchParams.set("response_type", "code");
56
+ if (config.defaultScope) {
57
+ url.searchParams.set("scope", config.defaultScope);
58
+ }
59
+ url.searchParams.set("state", state);
60
+
61
+ if (config.usePkce && codeChallenge) {
62
+ url.searchParams.set("code_challenge", codeChallenge);
63
+ url.searchParams.set("code_challenge_method", "S256");
64
+ }
65
+
66
+ // Extra params (e.g., Suunto subscription key)
67
+ if (config.extraAuthorizeParams) {
68
+ for (const [key, value] of Object.entries(config.extraAuthorizeParams)) {
69
+ url.searchParams.set(key, value);
70
+ }
71
+ }
72
+
73
+ return url.toString();
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Token Exchange
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Exchange an authorization code for tokens.
82
+ */
83
+ export async function exchangeCodeForTokens(
84
+ config: OAuthProviderConfig,
85
+ code: string,
86
+ redirectUri: string,
87
+ codeVerifier?: string,
88
+ ): Promise<OAuthTokenResponse> {
89
+ const body = new URLSearchParams({
90
+ grant_type: "authorization_code",
91
+ code,
92
+ redirect_uri: redirectUri,
93
+ });
94
+
95
+ if (config.usePkce && codeVerifier) {
96
+ body.set("code_verifier", codeVerifier);
97
+ }
98
+
99
+ const headers: Record<string, string> = {
100
+ "Content-Type": "application/x-www-form-urlencoded",
101
+ Accept: "application/json",
102
+ };
103
+
104
+ if (config.authMethod === "basic") {
105
+ const credentials = btoa(`${config.clientId}:${config.clientSecret}`);
106
+ headers.Authorization = `Basic ${credentials}`;
107
+ } else {
108
+ // Body-based auth
109
+ body.set("client_id", config.clientId);
110
+ body.set("client_secret", config.clientSecret);
111
+ }
112
+
113
+ const response = await fetch(config.endpoints.tokenUrl, {
114
+ method: "POST",
115
+ headers,
116
+ body: body.toString(),
117
+ });
118
+
119
+ if (!response.ok) {
120
+ const text = await response.text();
121
+ throw new Error(`Token exchange failed (${response.status}): ${text}`);
122
+ }
123
+
124
+ return (await response.json()) as OAuthTokenResponse;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Token Refresh
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * Refresh an expired access token.
133
+ */
134
+ export async function refreshAccessToken(
135
+ config: OAuthProviderConfig,
136
+ refreshToken: string,
137
+ ): Promise<OAuthTokenResponse> {
138
+ const body = new URLSearchParams({
139
+ grant_type: "refresh_token",
140
+ refresh_token: refreshToken,
141
+ });
142
+
143
+ const headers: Record<string, string> = {
144
+ "Content-Type": "application/x-www-form-urlencoded",
145
+ Accept: "application/json",
146
+ };
147
+
148
+ if (config.authMethod === "basic") {
149
+ const credentials = btoa(`${config.clientId}:${config.clientSecret}`);
150
+ headers.Authorization = `Basic ${credentials}`;
151
+ } else {
152
+ body.set("client_id", config.clientId);
153
+ body.set("client_secret", config.clientSecret);
154
+ }
155
+
156
+ const response = await fetch(config.endpoints.tokenUrl, {
157
+ method: "POST",
158
+ headers,
159
+ body: body.toString(),
160
+ });
161
+
162
+ if (!response.ok) {
163
+ const text = await response.text();
164
+ throw new Error(`Token refresh failed (${response.status}): ${text}`);
165
+ }
166
+
167
+ return (await response.json()) as OAuthTokenResponse;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Authenticated API requests (with retry)
172
+ // ---------------------------------------------------------------------------
173
+
174
+ const MAX_RETRIES = 3;
175
+ const RETRY_BASE_DELAY_MS = 15_000;
176
+
177
+ /**
178
+ * Make an authenticated request to a provider API with automatic retry on 429.
179
+ */
180
+ export async function makeAuthenticatedRequest<T = unknown>(
181
+ baseUrl: string,
182
+ endpoint: string,
183
+ accessToken: string,
184
+ options: {
185
+ method?: string;
186
+ params?: Record<string, string | number>;
187
+ body?: unknown;
188
+ headers?: Record<string, string>;
189
+ } = {},
190
+ ): Promise<T> {
191
+ const { method = "GET", params, body, headers: extraHeaders } = options;
192
+
193
+ const url = new URL(endpoint, baseUrl);
194
+ if (params) {
195
+ for (const [key, value] of Object.entries(params)) {
196
+ url.searchParams.set(key, String(value));
197
+ }
198
+ }
199
+
200
+ const headers: Record<string, string> = {
201
+ Authorization: `Bearer ${accessToken}`,
202
+ Accept: "application/json",
203
+ ...extraHeaders,
204
+ };
205
+
206
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
207
+ const response = await fetch(url.toString(), {
208
+ method,
209
+ headers,
210
+ body: body ? JSON.stringify(body) : undefined,
211
+ });
212
+
213
+ if (response.status === 429 && attempt < MAX_RETRIES) {
214
+ const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
215
+ await new Promise((resolve) => setTimeout(resolve, delay));
216
+ continue;
217
+ }
218
+
219
+ if (response.status === 401) {
220
+ throw new Error("Authorization expired — token refresh needed");
221
+ }
222
+
223
+ if (!response.ok) {
224
+ const text = await response.text();
225
+ throw new Error(`API request failed (${response.status}): ${text}`);
226
+ }
227
+
228
+ if (response.status === 204) {
229
+ return undefined as T;
230
+ }
231
+
232
+ const text = await response.text();
233
+ if (!text) {
234
+ return undefined as T;
235
+ }
236
+
237
+ const contentType = response.headers.get("content-type") ?? "";
238
+ if (contentType.includes("application/json")) {
239
+ return JSON.parse(text) as T;
240
+ }
241
+
242
+ return text as T;
243
+ }
244
+
245
+ throw new Error(`API request failed after ${MAX_RETRIES} retries (rate limited)`);
246
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Polar provider adapter — OAuth 2.0 + workout pull flow.
3
+ */
4
+
5
+ import { makeAuthenticatedRequest } from "./oauth";
6
+ import type {
7
+ NormalizedEvent,
8
+ OAuthProviderConfig,
9
+ ProviderAdapter,
10
+ ProviderCredentials,
11
+ } from "./types";
12
+
13
+ const POLAR_API_BASE = "https://www.polaraccesslink.com";
14
+ const POLAR_AUTHORIZE_URL = "https://flow.polar.com/oauth2/authorization";
15
+ const POLAR_TOKEN_URL = "https://polarremote.com/v2/oauth2/token";
16
+ const POLAR_WORKOUT_SCOPE = "accesslink.read_all";
17
+
18
+ const POLAR_WORKOUT_TYPE_MAPPINGS: Array<[string, string | null, string]> = [
19
+ ["RUNNING", null, "running"],
20
+ ["RUNNING", "RUNNING_ROAD", "running"],
21
+ ["RUNNING", "RUNNING_TRAIL", "trail_running"],
22
+ ["RUNNING", "RUNNING_TREADMILL", "treadmill"],
23
+ ["CYCLING", null, "cycling"],
24
+ ["CYCLING", "CYCLING_ROAD", "cycling"],
25
+ ["CYCLING", "CYCLING_MOUNTAIN", "mountain_biking"],
26
+ ["CYCLING", "CYCLING_INDOOR", "indoor_cycling"],
27
+ ["OTHER", "CYCLING_MOUNTAIN_BIKE", "mountain_biking"],
28
+ ["OTHER", "CYCLING_CYCLOCROSS", "cyclocross"],
29
+ ["SWIMMING", null, "swimming"],
30
+ ["SWIMMING", "SWIMMING_POOL", "pool_swimming"],
31
+ ["SWIMMING", "SWIMMING_OPEN_WATER", "open_water_swimming"],
32
+ ["OTHER", "AQUATICS_SWIMMING", "swimming"],
33
+ ["WALKING", null, "walking"],
34
+ ["OTHER", "WALKING", "walking"],
35
+ ["OTHER", "WALKING_NORDIC", "walking"],
36
+ ["OTHER", "HIKING", "hiking"],
37
+ ["OTHER", "MOUNTAINEERING", "mountaineering"],
38
+ ["OTHER", "WINTERSPORTS_CROSS_COUNTRY_SKIING", "cross_country_skiing"],
39
+ ["OTHER", "WINTERSPORTS_ALPINE_SKIING", "alpine_skiing"],
40
+ ["OTHER", "WINTERSPORTS_BACKCOUNTRY_SKIING", "backcountry_skiing"],
41
+ ["OTHER", "WINTERSPORTS_DOWNHILL_SKIING", "alpine_skiing"],
42
+ ["OTHER", "WINTERSPORTS_SNOWBOARDING", "snowboarding"],
43
+ ["OTHER", "WINTERSPORTS_SNOWSHOEING", "snowshoeing"],
44
+ ["OTHER", "WINTERSPORTS_ICE_SKATING", "ice_skating"],
45
+ ["STRENGTH_TRAINING", null, "strength_training"],
46
+ ["OTHER", "FITNESS_CARDIO", "cardio_training"],
47
+ ["OTHER", "FITNESS_ELLIPTICAL", "elliptical"],
48
+ ["OTHER", "FITNESS_INDOOR_ROWING", "rowing"],
49
+ ["OTHER", "FITNESS_STAIR_CLIMBING", "stair_climbing"],
50
+ ["OTHER", "WATERSPORTS_ROWING", "rowing"],
51
+ ["OTHER", "WATERSPORTS_KAYAKING", "kayaking"],
52
+ ["OTHER", "WATERSPORTS_CANOEING", "canoeing"],
53
+ ["OTHER", "WATERSPORTS_STAND_UP_PADDLING", "stand_up_paddleboarding"],
54
+ ["OTHER", "WATERSPORTS_SURFING", "surfing"],
55
+ ["OTHER", "WATERSPORTS_KITESURFING", "kitesurfing"],
56
+ ["OTHER", "WATERSPORTS_WINDSURFING", "windsurfing"],
57
+ ["OTHER", "WATERSPORTS_SAILING", "sailing"],
58
+ ["OTHER", "WATERSPORTS_WATERSKI", "other"],
59
+ ["BASKETBALL", null, "basketball"],
60
+ ["SOCCER", null, "soccer"],
61
+ ["OTHER", "TEAMSPORTS_SOCCER", "soccer"],
62
+ ["OTHER", "TEAMSPORTS_FOOTBALL", "football"],
63
+ ["OTHER", "TEAMSPORTS_AMERICAN_FOOTBALL", "american_football"],
64
+ ["OTHER", "TEAMSPORTS_BASEBALL", "baseball"],
65
+ ["OTHER", "TEAMSPORTS_BASKETBALL", "basketball"],
66
+ ["OTHER", "TEAMSPORTS_VOLLEYBALL", "volleyball"],
67
+ ["OTHER", "TEAMSPORTS_HANDBALL", "handball"],
68
+ ["OTHER", "TEAMSPORTS_RUGBY", "rugby"],
69
+ ["OTHER", "TEAMSPORTS_HOCKEY", "hockey"],
70
+ ["OTHER", "TEAMSPORTS_FLOORBALL", "floorball"],
71
+ ["TENNIS", null, "tennis"],
72
+ ["OTHER", "RACKET_SPORTS_TENNIS", "tennis"],
73
+ ["OTHER", "RACKET_SPORTS_BADMINTON", "badminton"],
74
+ ["OTHER", "RACKET_SPORTS_SQUASH", "squash"],
75
+ ["OTHER", "RACKET_SPORTS_TABLE_TENNIS", "table_tennis"],
76
+ ["OTHER", "RACKET_SPORTS_PADEL", "padel"],
77
+ ["OTHER", "RACKET_SPORTS_PICKLEBALL", "pickleball"],
78
+ ["OTHER", "COMBAT_SPORTS_BOXING", "boxing"],
79
+ ["OTHER", "COMBAT_SPORTS_MARTIAL_ARTS", "martial_arts"],
80
+ ["OTHER", "OUTDOOR_CLIMBING", "rock_climbing"],
81
+ ["OTHER", "INDOOR_CLIMBING", "rock_climbing"],
82
+ ["OTHER", "SPORTS_GOLF", "golf"],
83
+ ["MOTORSPORTS", null, "motorcycling"],
84
+ ["OTHER", "MOTORSPORTS", "motorcycling"],
85
+ ["OTHER", "DANCE", "dance"],
86
+ ["OTHER", "AEROBICS", "aerobics"],
87
+ ["OTHER", null, "other"],
88
+ ];
89
+
90
+ type PolarExercise = {
91
+ id: string;
92
+ device?: string;
93
+ sport: string;
94
+ detailed_sport_info?: string | null;
95
+ start_time: string;
96
+ start_time_utc_offset: number;
97
+ duration: string;
98
+ calories?: number | null;
99
+ distance?: number | null;
100
+ heart_rate?: {
101
+ average?: number | null;
102
+ maximum?: number | null;
103
+ };
104
+ };
105
+
106
+ function resolvePolarWorkoutType(sport: string, detailed?: string | null): string {
107
+ if (detailed) {
108
+ const match = POLAR_WORKOUT_TYPE_MAPPINGS.find(
109
+ ([s, detail]) => s === sport && detail === detailed,
110
+ );
111
+ if (match) {
112
+ return match[2];
113
+ }
114
+ }
115
+ const fallback = POLAR_WORKOUT_TYPE_MAPPINGS.find(
116
+ ([s, detail]) => s === sport && detail === null,
117
+ );
118
+ if (fallback) {
119
+ return fallback[2];
120
+ }
121
+ return "other";
122
+ }
123
+
124
+ function parsePolarDuration(duration: string): number {
125
+ const regex = /PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/;
126
+ const match = regex.exec(duration);
127
+ if (!match) return 0;
128
+ const [, hours = "0", minutes = "0", seconds = "0"] = match;
129
+ return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
130
+ }
131
+
132
+ function normalizePolarExercise(exercise: PolarExercise): NormalizedEvent {
133
+ const durationSeconds = parsePolarDuration(exercise.duration);
134
+ const baseStart = Date.parse(exercise.start_time);
135
+ const offsetMs = exercise.start_time_utc_offset * 60_000;
136
+ const startDatetime = baseStart + offsetMs;
137
+ const endDatetime = startDatetime + durationSeconds * 1000;
138
+
139
+ const heartRateAvg = exercise.heart_rate?.average ?? undefined;
140
+ const heartRateMax = exercise.heart_rate?.maximum ?? undefined;
141
+
142
+ return {
143
+ category: "workout",
144
+ type: resolvePolarWorkoutType(exercise.sport, exercise.detailed_sport_info),
145
+ sourceName: exercise.device ?? "Polar",
146
+ deviceModel: exercise.device,
147
+ durationSeconds: durationSeconds || undefined,
148
+ startDatetime,
149
+ endDatetime,
150
+ externalId: `polar-${exercise.id}`,
151
+ heartRateAvg,
152
+ heartRateMax,
153
+ energyBurned: exercise.calories ?? undefined,
154
+ distance: exercise.distance ?? undefined,
155
+ };
156
+ }
157
+
158
+ function buildPolarOAuthConfig(credentials: ProviderCredentials): OAuthProviderConfig {
159
+ return {
160
+ endpoints: {
161
+ authorizeUrl: POLAR_AUTHORIZE_URL,
162
+ tokenUrl: POLAR_TOKEN_URL,
163
+ apiBaseUrl: POLAR_API_BASE,
164
+ },
165
+ clientId: credentials.clientId,
166
+ clientSecret: credentials.clientSecret,
167
+ defaultScope: POLAR_WORKOUT_SCOPE,
168
+ usePkce: false,
169
+ authMethod: "basic",
170
+ };
171
+ }
172
+
173
+ async function registerMember(accessToken: string, appUserId: string): Promise<void> {
174
+ try {
175
+ await fetch(`${POLAR_API_BASE}/v3/users`, {
176
+ method: "POST",
177
+ headers: {
178
+ Authorization: `Bearer ${accessToken}`,
179
+ "Content-Type": "application/json",
180
+ Accept: "application/json",
181
+ },
182
+ body: JSON.stringify({ "member-id": appUserId }),
183
+ });
184
+ } catch (error) {
185
+ console.warn("Polar member registration failed", error);
186
+ }
187
+ }
188
+
189
+ async function fetchPolarWorkouts(
190
+ accessToken: string,
191
+ startDate: number,
192
+ endDate: number,
193
+ _credentials?: ProviderCredentials,
194
+ ): Promise<NormalizedEvent[]> {
195
+ const exercises = await makeAuthenticatedRequest<PolarExercise[]>(
196
+ POLAR_API_BASE,
197
+ "/v3/exercises",
198
+ accessToken,
199
+ );
200
+
201
+ const records = Array.isArray(exercises) ? exercises : [];
202
+ return records
203
+ .map(normalizePolarExercise)
204
+ .filter((event) => event.startDatetime >= startDate && event.startDatetime <= endDate);
205
+ }
206
+
207
+ export const polarProvider: ProviderAdapter = {
208
+ name: "polar",
209
+ oauthConfig: buildPolarOAuthConfig,
210
+ getUserInfo: async (_accessToken, tokenResponse) => ({
211
+ providerUserId: tokenResponse?.x_user_id != null ? String(tokenResponse.x_user_id) : null,
212
+ username: null,
213
+ }),
214
+ postConnect: async (accessToken, _tokenResponse, appUserId) => {
215
+ if (appUserId) {
216
+ await registerMember(accessToken, appUserId);
217
+ }
218
+ },
219
+ fetchEvents: fetchPolarWorkouts,
220
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Provider registry — maps provider names to their OAuth configs and data fetchers.
3
+ */
4
+
5
+ import { garminProvider } from "./garmin";
6
+ import { polarProvider } from "./polar";
7
+ import { stravaProvider } from "./strava";
8
+ import { suuntoProvider } from "./suunto";
9
+ import type { ProviderAdapter } from "./types";
10
+ import { whoopProvider } from "./whoop";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Provider definition
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const PROVIDERS: Record<string, ProviderAdapter> = {
17
+ strava: stravaProvider,
18
+ garmin: garminProvider,
19
+ polar: polarProvider,
20
+ whoop: whoopProvider,
21
+ suunto: suuntoProvider,
22
+ };
23
+
24
+ /**
25
+ * Get the provider definition for a given provider name.
26
+ * Returns undefined if the provider is not yet implemented.
27
+ */
28
+ export function getProvider(name: string): ProviderAdapter | undefined {
29
+ return PROVIDERS[name];
30
+ }
31
+
32
+ /**
33
+ * Get all implemented provider names.
34
+ */
35
+ export function getImplementedProviders(): string[] {
36
+ return Object.keys(PROVIDERS);
37
+ }