@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,195 @@
1
+ /**
2
+ * Tests for Strava provider normalization logic.
3
+ *
4
+ * These tests verify the pure normalization functions without hitting
5
+ * the Strava API (no network calls).
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { normalizeStravaActivity } from "./strava";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Factory helper — builds a minimal valid Strava activity
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function makeActivity(overrides: Record<string, unknown> = {}) {
16
+ return {
17
+ id: 12345,
18
+ name: "Morning Run",
19
+ type: "Run",
20
+ sport_type: "Run",
21
+ start_date: "2026-03-15T07:30:00Z",
22
+ elapsed_time: 1800, // 30 minutes
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Tests
29
+ // ---------------------------------------------------------------------------
30
+
31
+ describe("Strava normalizeActivity", () => {
32
+ it("normalizes a basic running activity", () => {
33
+ const event = normalizeStravaActivity(makeActivity());
34
+
35
+ expect(event.category).toBe("workout");
36
+ expect(event.type).toBe("running");
37
+ expect(event.externalId).toBe("strava-12345");
38
+ expect(event.durationSeconds).toBe(1800);
39
+ expect(event.sourceName).toBe("Strava");
40
+ });
41
+
42
+ it("calculates start and end timestamps correctly", () => {
43
+ const event = normalizeStravaActivity(
44
+ makeActivity({
45
+ start_date: "2026-03-15T10:00:00Z",
46
+ elapsed_time: 3600, // 1 hour
47
+ }),
48
+ );
49
+
50
+ const expectedStart = new Date("2026-03-15T10:00:00Z").getTime();
51
+ expect(event.startDatetime).toBe(expectedStart);
52
+ expect(event.endDatetime).toBe(expectedStart + 3600 * 1000);
53
+ });
54
+
55
+ it("maps sport_type to unified workout type", () => {
56
+ const cases: [string, string, string][] = [
57
+ ["Ride", "Ride", "cycling"],
58
+ ["MountainBikeRide", "Ride", "mountain_biking"],
59
+ ["Swim", "Swim", "swimming"],
60
+ ["Hike", "Hike", "hiking"],
61
+ ["WeightTraining", "WeightTraining", "strength_training"],
62
+ ["Yoga", "Yoga", "yoga"],
63
+ ["AlpineSki", "AlpineSki", "alpine_skiing"],
64
+ ["Rowing", "Rowing", "rowing"],
65
+ ["VirtualRide", "Ride", "indoor_cycling"],
66
+ ["TrailRun", "Run", "trail_running"],
67
+ ["Pickleball", "Pickleball", "pickleball"],
68
+ ];
69
+
70
+ for (const [sportType, activityType, expected] of cases) {
71
+ const event = normalizeStravaActivity(
72
+ makeActivity({ sport_type: sportType, type: activityType }),
73
+ );
74
+ expect(event.type).toBe(expected);
75
+ }
76
+ });
77
+
78
+ it("falls back to activity type when sport_type is unknown", () => {
79
+ const event = normalizeStravaActivity(makeActivity({ sport_type: "SomeNewType", type: "Run" }));
80
+ expect(event.type).toBe("running");
81
+ });
82
+
83
+ it("returns 'other' for completely unknown types", () => {
84
+ const event = normalizeStravaActivity(
85
+ makeActivity({ sport_type: "SomeNewType", type: "SomeOtherType" }),
86
+ );
87
+ expect(event.type).toBe("other");
88
+ });
89
+
90
+ it("includes heart rate data when available", () => {
91
+ const event = normalizeStravaActivity(
92
+ makeActivity({
93
+ average_heartrate: 145,
94
+ max_heartrate: 172,
95
+ }),
96
+ );
97
+
98
+ expect(event.heartRateAvg).toBe(145);
99
+ expect(event.heartRateMax).toBe(172);
100
+ });
101
+
102
+ it("includes distance and speed data", () => {
103
+ const event = normalizeStravaActivity(
104
+ makeActivity({
105
+ distance: 10000, // 10km in meters
106
+ average_speed: 3.5, // m/s
107
+ max_speed: 4.2,
108
+ }),
109
+ );
110
+
111
+ expect(event.distance).toBe(10000);
112
+ expect(event.averageSpeed).toBe(3.5);
113
+ expect(event.maxSpeed).toBe(4.2);
114
+ });
115
+
116
+ it("includes elevation data", () => {
117
+ const event = normalizeStravaActivity(
118
+ makeActivity({
119
+ total_elevation_gain: 250,
120
+ elev_high: 1200,
121
+ elev_low: 950,
122
+ }),
123
+ );
124
+
125
+ expect(event.totalElevationGain).toBe(250);
126
+ expect(event.elevHigh).toBe(1200);
127
+ expect(event.elevLow).toBe(950);
128
+ });
129
+
130
+ it("includes power data", () => {
131
+ const event = normalizeStravaActivity(
132
+ makeActivity({
133
+ average_watts: 220,
134
+ max_watts: 450,
135
+ }),
136
+ );
137
+
138
+ expect(event.averageWatts).toBe(220);
139
+ expect(event.maxWatts).toBe(450);
140
+ });
141
+
142
+ it("prefers calories over kilojoules for energy", () => {
143
+ const event = normalizeStravaActivity(
144
+ makeActivity({
145
+ calories: 500,
146
+ kilojoules: 2000,
147
+ }),
148
+ );
149
+
150
+ expect(event.energyBurned).toBe(500);
151
+ });
152
+
153
+ it("converts kilojoules to kcal when calories not available", () => {
154
+ const event = normalizeStravaActivity(
155
+ makeActivity({
156
+ kilojoules: 1000,
157
+ }),
158
+ );
159
+
160
+ // 1000 * 0.239 = 239
161
+ expect(event.energyBurned).toBeCloseTo(239, 0);
162
+ });
163
+
164
+ it("includes moving time", () => {
165
+ const event = normalizeStravaActivity(
166
+ makeActivity({
167
+ moving_time: 1500,
168
+ }),
169
+ );
170
+
171
+ expect(event.movingTimeSeconds).toBe(1500);
172
+ });
173
+
174
+ it("uses device_name as sourceName when available", () => {
175
+ const event = normalizeStravaActivity(
176
+ makeActivity({
177
+ device_name: "Garmin Edge 1040",
178
+ }),
179
+ );
180
+
181
+ expect(event.sourceName).toBe("Garmin Edge 1040");
182
+ expect(event.deviceModel).toBe("Garmin Edge 1040");
183
+ });
184
+
185
+ it("omits optional fields when not present in activity", () => {
186
+ const event = normalizeStravaActivity(makeActivity());
187
+
188
+ expect(event.heartRateAvg).toBeUndefined();
189
+ expect(event.heartRateMax).toBeUndefined();
190
+ expect(event.distance).toBeUndefined();
191
+ expect(event.energyBurned).toBeUndefined();
192
+ expect(event.averageWatts).toBeUndefined();
193
+ expect(event.deviceModel).toBeUndefined();
194
+ });
195
+ });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Strava provider adapter.
3
+ *
4
+ * Fetches activities from Strava API and normalizes them to our event format.
5
+ */
6
+
7
+ import { makeAuthenticatedRequest } from "./oauth";
8
+ import type {
9
+ NormalizedEvent,
10
+ OAuthProviderConfig,
11
+ ProviderAdapter,
12
+ ProviderCredentials,
13
+ ProviderUserInfo,
14
+ } from "./types";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // OAuth config
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export function stravaOAuthConfig(credentials: ProviderCredentials): OAuthProviderConfig {
21
+ return {
22
+ endpoints: {
23
+ authorizeUrl: "https://www.strava.com/oauth/authorize",
24
+ tokenUrl: "https://www.strava.com/api/v3/oauth/token",
25
+ apiBaseUrl: "https://www.strava.com/api/v3",
26
+ },
27
+ clientId: credentials.clientId,
28
+ clientSecret: credentials.clientSecret,
29
+ defaultScope: "activity:read_all,profile:read_all",
30
+ usePkce: false,
31
+ authMethod: "body",
32
+ };
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Strava API types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface StravaActivity {
40
+ id: number;
41
+ name: string;
42
+ type: string;
43
+ sport_type: string;
44
+ start_date: string; // ISO 8601 UTC
45
+ elapsed_time: number; // seconds
46
+ distance?: number; // meters
47
+ moving_time?: number;
48
+ total_elevation_gain?: number;
49
+ elev_high?: number;
50
+ elev_low?: number;
51
+ average_heartrate?: number;
52
+ max_heartrate?: number;
53
+ average_speed?: number; // m/s
54
+ max_speed?: number;
55
+ average_watts?: number;
56
+ max_watts?: number;
57
+ kilojoules?: number;
58
+ calories?: number;
59
+ device_name?: string;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Workout type mapping (Strava sport_type → unified type)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const WORKOUT_TYPE_MAP: Record<string, string> = {
67
+ Run: "running",
68
+ TrailRun: "trail_running",
69
+ VirtualRun: "running",
70
+ Ride: "cycling",
71
+ MountainBikeRide: "mountain_biking",
72
+ GravelRide: "cycling",
73
+ EBikeRide: "e_biking",
74
+ EMountainBikeRide: "e_biking",
75
+ VirtualRide: "indoor_cycling",
76
+ Swim: "swimming",
77
+ Walk: "walking",
78
+ Hike: "hiking",
79
+ AlpineSki: "alpine_skiing",
80
+ BackcountrySki: "backcountry_skiing",
81
+ NordicSki: "cross_country_skiing",
82
+ Snowboard: "snowboarding",
83
+ Snowshoe: "snowshoeing",
84
+ IceSkate: "ice_skating",
85
+ Rowing: "rowing",
86
+ Kayaking: "kayaking",
87
+ Canoeing: "canoeing",
88
+ StandUpPaddling: "stand_up_paddleboarding",
89
+ Surfing: "surfing",
90
+ Kitesurf: "kitesurfing",
91
+ Windsurf: "windsurfing",
92
+ Sail: "sailing",
93
+ WeightTraining: "strength_training",
94
+ Yoga: "yoga",
95
+ Pilates: "pilates",
96
+ Crossfit: "cardio_training",
97
+ Elliptical: "elliptical",
98
+ StairStepper: "stair_climbing",
99
+ HighIntensityIntervalTraining: "cardio_training",
100
+ Pickleball: "pickleball",
101
+ Squash: "squash",
102
+ Badminton: "badminton",
103
+ TableTennis: "table_tennis",
104
+ Tennis: "tennis",
105
+ Soccer: "soccer",
106
+ RockClimbing: "rock_climbing",
107
+ Golf: "golf",
108
+ Skateboard: "skateboarding",
109
+ InlineSkate: "inline_skating",
110
+ VirtualRow: "rowing_machine",
111
+ };
112
+
113
+ function getUnifiedWorkoutType(sportType: string, activityType: string): string {
114
+ return WORKOUT_TYPE_MAP[sportType] ?? WORKOUT_TYPE_MAP[activityType] ?? "other";
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Normalization
119
+ // ---------------------------------------------------------------------------
120
+
121
+ function normalizeActivity(activity: StravaActivity): NormalizedEvent {
122
+ const startMs = new Date(activity.start_date).getTime();
123
+ const endMs = startMs + activity.elapsed_time * 1000;
124
+
125
+ // Energy: prefer calories, fallback to kilojoules * 0.239
126
+ let energyBurned: number | undefined;
127
+ if (activity.calories != null) {
128
+ energyBurned = activity.calories;
129
+ } else if (activity.kilojoules != null) {
130
+ energyBurned = activity.kilojoules * 0.239;
131
+ }
132
+
133
+ return {
134
+ category: "workout",
135
+ type: getUnifiedWorkoutType(activity.sport_type, activity.type),
136
+ sourceName: activity.device_name ?? "Strava",
137
+ deviceModel: activity.device_name,
138
+ durationSeconds: activity.elapsed_time,
139
+ startDatetime: startMs,
140
+ endDatetime: endMs,
141
+ externalId: `strava-${activity.id}`,
142
+
143
+ heartRateAvg: activity.average_heartrate,
144
+ heartRateMax: activity.max_heartrate,
145
+ energyBurned,
146
+ distance: activity.distance,
147
+ averageSpeed: activity.average_speed,
148
+ maxSpeed: activity.max_speed,
149
+ averageWatts: activity.average_watts,
150
+ maxWatts: activity.max_watts,
151
+ totalElevationGain: activity.total_elevation_gain,
152
+ elevHigh: activity.elev_high,
153
+ elevLow: activity.elev_low,
154
+ movingTimeSeconds: activity.moving_time,
155
+ };
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Fetch workouts
160
+ // ---------------------------------------------------------------------------
161
+
162
+ const API_BASE = "https://www.strava.com/api/v3";
163
+ const PER_PAGE = 200;
164
+
165
+ /**
166
+ * Fetch all activities from Strava within a date range.
167
+ * Uses page-based pagination, fetching up to 200 per page.
168
+ */
169
+ export async function fetchStravaWorkouts(
170
+ accessToken: string,
171
+ startDate: number,
172
+ endDate: number,
173
+ _credentials?: ProviderCredentials,
174
+ ): Promise<NormalizedEvent[]> {
175
+ const allEvents: NormalizedEvent[] = [];
176
+ let page = 1;
177
+
178
+ const after = Math.floor(startDate / 1000);
179
+ const before = Math.floor(endDate / 1000);
180
+
181
+ while (true) {
182
+ const activities = await makeAuthenticatedRequest<StravaActivity[]>(
183
+ API_BASE,
184
+ "/athlete/activities",
185
+ accessToken,
186
+ {
187
+ params: {
188
+ after: String(after),
189
+ before: String(before),
190
+ page: String(page),
191
+ per_page: String(PER_PAGE),
192
+ },
193
+ },
194
+ );
195
+
196
+ for (const activity of activities) {
197
+ try {
198
+ allEvents.push(normalizeActivity(activity));
199
+ } catch {
200
+ // Skip activities that fail to normalize
201
+ console.warn(`Failed to normalize Strava activity ${activity.id}`);
202
+ }
203
+ }
204
+
205
+ // Last page when we get fewer than per_page results
206
+ if (activities.length < PER_PAGE) break;
207
+ page++;
208
+ }
209
+
210
+ return allEvents;
211
+ }
212
+
213
+ /**
214
+ * Normalize a single Strava activity (for webhook push processing).
215
+ */
216
+ export function normalizeStravaActivity(activityJson: StravaActivity): NormalizedEvent {
217
+ return normalizeActivity(activityJson);
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Provider user info
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /**
225
+ * Fetch the authenticated athlete's profile from Strava.
226
+ */
227
+ export async function getStravaUserInfo(
228
+ accessToken: string,
229
+ _tokenResponse?: unknown,
230
+ _appUserId?: string,
231
+ _credentials?: ProviderCredentials,
232
+ ): Promise<ProviderUserInfo> {
233
+ try {
234
+ const athlete = await makeAuthenticatedRequest<{
235
+ id: number;
236
+ username?: string;
237
+ }>(API_BASE, "/athlete", accessToken);
238
+
239
+ return {
240
+ providerUserId: String(athlete.id),
241
+ username: athlete.username ?? null,
242
+ };
243
+ } catch {
244
+ return { providerUserId: null, username: null };
245
+ }
246
+ }
247
+
248
+ export const stravaProvider: ProviderAdapter = {
249
+ name: "strava",
250
+ oauthConfig: stravaOAuthConfig,
251
+ getUserInfo: getStravaUserInfo,
252
+ fetchEvents: fetchStravaWorkouts,
253
+ };