@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,415 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformActivity } from "./activity.js";
3
+ import type { DetailedActivity, SummaryActivity, StreamSet, Lap } from "./types.js";
4
+
5
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
6
+
7
+ const baseSummaryActivity: SummaryActivity = {
8
+ resource_state: 2,
9
+ athlete: { id: 134815, resource_state: 1 },
10
+ name: "Happy Friday",
11
+ distance: 24931.4,
12
+ moving_time: 4500,
13
+ elapsed_time: 4500,
14
+ total_elevation_gain: 0,
15
+ type: "Ride",
16
+ sport_type: "MountainBikeRide",
17
+ workout_type: null,
18
+ id: 154504250376823,
19
+ external_id: "garmin_push_12345678987654321",
20
+ upload_id: 987654321234567900000,
21
+ start_date: "2018-05-02T12:15:09Z",
22
+ start_date_local: "2018-05-02T05:15:09Z",
23
+ timezone: "(GMT-08:00) America/Los_Angeles",
24
+ utc_offset: -25200,
25
+ start_latlng: null,
26
+ end_latlng: null,
27
+ location_city: null,
28
+ location_state: null,
29
+ location_country: "United States",
30
+ achievement_count: 0,
31
+ kudos_count: 3,
32
+ comment_count: 1,
33
+ athlete_count: 1,
34
+ photo_count: 0,
35
+ map: {
36
+ id: "a12345678987654321",
37
+ summary_polyline: null,
38
+ resource_state: 2,
39
+ polyline: null,
40
+ },
41
+ trainer: true,
42
+ commute: false,
43
+ manual: false,
44
+ private: false,
45
+ flagged: false,
46
+ gear_id: "b12345678987654321",
47
+ from_accepted_tag: false,
48
+ average_speed: 5.54,
49
+ max_speed: 11,
50
+ average_cadence: 67.1,
51
+ average_watts: 175.3,
52
+ weighted_average_watts: 210,
53
+ kilojoules: 788.7,
54
+ device_watts: true,
55
+ has_heartrate: true,
56
+ average_heartrate: 140.3,
57
+ max_heartrate: 178,
58
+ max_watts: 406,
59
+ pr_count: 0,
60
+ total_photo_count: 1,
61
+ has_kudoed: false,
62
+ suffer_score: 82,
63
+ device_name: "Garmin Edge 1030",
64
+ };
65
+
66
+ const baseDetailedActivity: DetailedActivity = {
67
+ ...baseSummaryActivity,
68
+ resource_state: 3,
69
+ id: 12345678987654320,
70
+ distance: 28099,
71
+ moving_time: 4207,
72
+ elapsed_time: 4410,
73
+ total_elevation_gain: 516,
74
+ start_date: "2018-02-16T14:52:54Z",
75
+ start_latlng: [37.83, -122.26],
76
+ end_latlng: [37.83, -122.26],
77
+ calories: 870.2,
78
+ elev_high: 446.6,
79
+ elev_low: 17.2,
80
+ average_speed: 6.679,
81
+ max_speed: 18.5,
82
+ average_cadence: 78.5,
83
+ average_watts: 185.5,
84
+ max_watts: 743,
85
+ kilojoules: 780.5,
86
+ has_heartrate: false,
87
+ average_heartrate: undefined,
88
+ max_heartrate: undefined,
89
+ description: "",
90
+ gear: {
91
+ id: "b12345678987654321",
92
+ primary: true,
93
+ name: "Tarmac",
94
+ resource_state: 2,
95
+ distance: 32547610,
96
+ },
97
+ segment_efforts: [],
98
+ splits_metric: [],
99
+ laps: [],
100
+ embed_token: "abc123",
101
+ partner_brand_tag: null,
102
+ map: {
103
+ id: "a1410355832",
104
+ polyline: "encoded_polyline_data",
105
+ resource_state: 3,
106
+ summary_polyline: "summary_encoded_data",
107
+ },
108
+ };
109
+
110
+ // ─── Tests ───────────────────────────────────────────────────────────────────
111
+
112
+ describe("transformActivity", () => {
113
+ describe("metadata", () => {
114
+ it("sets summary_id from activity id", () => {
115
+ const result = transformActivity(baseDetailedActivity);
116
+ expect(result.metadata.summary_id).toBe(
117
+ String(baseDetailedActivity.id),
118
+ );
119
+ });
120
+
121
+ it("sets start_time from start_date", () => {
122
+ const result = transformActivity(baseDetailedActivity);
123
+ expect(result.metadata.start_time).toBe("2018-02-16T14:52:54Z");
124
+ });
125
+
126
+ it("computes end_time from start_date + elapsed_time", () => {
127
+ const result = transformActivity(baseDetailedActivity);
128
+ const expected = new Date(
129
+ new Date("2018-02-16T14:52:54Z").getTime() + 4410 * 1000,
130
+ ).toISOString();
131
+ expect(result.metadata.end_time).toBe(expected);
132
+ });
133
+
134
+ it("maps sport_type to Terra ActivityType", () => {
135
+ const result = transformActivity(baseDetailedActivity);
136
+ expect(result.metadata.type).toBe(1); // Biking for MountainBikeRide
137
+ });
138
+
139
+ it("sets upload_type to 1 (Automatic) for non-manual activities", () => {
140
+ const result = transformActivity(baseDetailedActivity);
141
+ expect(result.metadata.upload_type).toBe(1);
142
+ });
143
+
144
+ it("sets upload_type to 2 (Manual) for manual activities", () => {
145
+ const manual = { ...baseDetailedActivity, manual: true };
146
+ const result = transformActivity(manual);
147
+ expect(result.metadata.upload_type).toBe(2);
148
+ });
149
+
150
+ it("sets name from activity name", () => {
151
+ const result = transformActivity(baseDetailedActivity);
152
+ expect(result.metadata.name).toBe("Happy Friday");
153
+ });
154
+
155
+ it("maps location fields", () => {
156
+ const result = transformActivity(baseDetailedActivity);
157
+ expect(result.metadata.country).toBe("United States");
158
+ });
159
+ });
160
+
161
+ describe("active_durations_data", () => {
162
+ it("sets activity_seconds from moving_time", () => {
163
+ const result = transformActivity(baseDetailedActivity);
164
+ expect(result.active_durations_data.activity_seconds).toBe(4207);
165
+ });
166
+ });
167
+
168
+ describe("calories_data", () => {
169
+ it("sets total_burned_calories for detailed activities", () => {
170
+ const result = transformActivity(baseDetailedActivity);
171
+ expect(result.calories_data?.total_burned_calories).toBe(870.2);
172
+ });
173
+
174
+ it("returns undefined for summary activities", () => {
175
+ const result = transformActivity(baseSummaryActivity);
176
+ expect(result.calories_data).toBeUndefined();
177
+ });
178
+ });
179
+
180
+ describe("distance_data", () => {
181
+ it("sets distance_meters from activity distance", () => {
182
+ const result = transformActivity(baseDetailedActivity);
183
+ expect(result.distance_data?.summary?.distance_meters).toBe(28099);
184
+ });
185
+
186
+ it("includes elevation for detailed activities", () => {
187
+ const result = transformActivity(baseDetailedActivity);
188
+ expect(
189
+ result.distance_data?.summary?.elevation?.gain_actual_meters,
190
+ ).toBe(516);
191
+ expect(result.distance_data?.summary?.elevation?.max_meters).toBe(446.6);
192
+ expect(result.distance_data?.summary?.elevation?.min_meters).toBe(17.2);
193
+ });
194
+ });
195
+
196
+ describe("heart_rate_data", () => {
197
+ it("maps summary HR from activity fields", () => {
198
+ const withHr = {
199
+ ...baseDetailedActivity,
200
+ has_heartrate: true,
201
+ average_heartrate: 145,
202
+ max_heartrate: 180,
203
+ };
204
+ const result = transformActivity(withHr);
205
+ expect(result.heart_rate_data?.summary?.avg_hr_bpm).toBe(145);
206
+ expect(result.heart_rate_data?.summary?.max_hr_bpm).toBe(180);
207
+ });
208
+
209
+ it("maps detailed HR from streams", () => {
210
+ const streams: StreamSet = {
211
+ time: {
212
+ type: "time",
213
+ data: [0, 5, 10],
214
+ series_type: "distance",
215
+ original_size: 3,
216
+ resolution: "high",
217
+ },
218
+ heartrate: {
219
+ type: "heartrate",
220
+ data: [120, 135, 150],
221
+ series_type: "distance",
222
+ original_size: 3,
223
+ resolution: "high",
224
+ },
225
+ };
226
+ const result = transformActivity(baseSummaryActivity, { streams });
227
+ expect(result.heart_rate_data?.detailed?.hr_samples).toHaveLength(3);
228
+ expect(result.heart_rate_data?.detailed?.hr_samples?.[0].bpm).toBe(120);
229
+ });
230
+
231
+ it("returns undefined when no HR data", () => {
232
+ const result = transformActivity(baseDetailedActivity);
233
+ expect(result.heart_rate_data).toBeUndefined();
234
+ });
235
+ });
236
+
237
+ describe("movement_data", () => {
238
+ it("maps speed and cadence from activity fields", () => {
239
+ const result = transformActivity(baseDetailedActivity);
240
+ expect(result.movement_data?.avg_speed_meters_per_second).toBe(6.679);
241
+ expect(result.movement_data?.max_speed_meters_per_second).toBe(18.5);
242
+ expect(result.movement_data?.avg_cadence_rpm).toBe(78.5);
243
+ });
244
+
245
+ it("maps speed samples from velocity_smooth stream", () => {
246
+ const streams: StreamSet = {
247
+ time: {
248
+ type: "time",
249
+ data: [0, 5],
250
+ series_type: "distance",
251
+ original_size: 2,
252
+ resolution: "high",
253
+ },
254
+ velocity_smooth: {
255
+ type: "velocity_smooth",
256
+ data: [3.5, 4.2],
257
+ series_type: "distance",
258
+ original_size: 2,
259
+ resolution: "high",
260
+ },
261
+ };
262
+ const result = transformActivity(baseSummaryActivity, { streams });
263
+ expect(result.movement_data?.speed_samples).toHaveLength(2);
264
+ expect(
265
+ result.movement_data?.speed_samples?.[0].speed_meters_per_second,
266
+ ).toBe(3.5);
267
+ });
268
+ });
269
+
270
+ describe("power_data", () => {
271
+ it("maps power summary from activity fields", () => {
272
+ const result = transformActivity(baseDetailedActivity);
273
+ expect(result.power_data?.avg_watts).toBe(185.5);
274
+ expect(result.power_data?.max_watts).toBe(743);
275
+ });
276
+
277
+ it("maps power samples from watts stream", () => {
278
+ const streams: StreamSet = {
279
+ time: {
280
+ type: "time",
281
+ data: [0, 5, 10],
282
+ series_type: "distance",
283
+ original_size: 3,
284
+ resolution: "high",
285
+ },
286
+ watts: {
287
+ type: "watts",
288
+ data: [200, 250, 180],
289
+ series_type: "distance",
290
+ original_size: 3,
291
+ resolution: "high",
292
+ },
293
+ };
294
+ const result = transformActivity(baseSummaryActivity, { streams });
295
+ expect(result.power_data?.power_samples).toHaveLength(3);
296
+ expect(result.power_data?.power_samples?.[1].watts).toBe(250);
297
+ });
298
+ });
299
+
300
+ describe("position_data", () => {
301
+ it("maps start/end positions from activity", () => {
302
+ const result = transformActivity(baseDetailedActivity);
303
+ expect(result.position_data?.start_pos_lat_lng_deg).toEqual([
304
+ 37.83, -122.26,
305
+ ]);
306
+ expect(result.position_data?.end_pos_lat_lng_deg).toEqual([
307
+ 37.83, -122.26,
308
+ ]);
309
+ });
310
+
311
+ it("maps position samples from latlng stream", () => {
312
+ const streams: StreamSet = {
313
+ time: {
314
+ type: "time",
315
+ data: [0, 5],
316
+ series_type: "distance",
317
+ original_size: 2,
318
+ resolution: "high",
319
+ },
320
+ latlng: {
321
+ type: "latlng",
322
+ data: [
323
+ [37.83, -122.26],
324
+ [37.84, -122.25],
325
+ ],
326
+ series_type: "distance",
327
+ original_size: 2,
328
+ resolution: "high",
329
+ },
330
+ };
331
+ const result = transformActivity(baseDetailedActivity, { streams });
332
+ expect(result.position_data?.position_samples).toHaveLength(2);
333
+ expect(
334
+ result.position_data?.position_samples?.[0].coords_lat_lng_deg,
335
+ ).toEqual([37.83, -122.26]);
336
+ });
337
+
338
+ it("returns undefined when no position data", () => {
339
+ const noPos = {
340
+ ...baseSummaryActivity,
341
+ start_latlng: null,
342
+ end_latlng: null,
343
+ };
344
+ const result = transformActivity(noPos);
345
+ expect(result.position_data).toBeUndefined();
346
+ });
347
+ });
348
+
349
+ describe("polyline_map_data", () => {
350
+ it("maps summary_polyline from activity map", () => {
351
+ const result = transformActivity(baseDetailedActivity);
352
+ expect(result.polyline_map_data?.summary_polyline).toBe(
353
+ "summary_encoded_data",
354
+ );
355
+ });
356
+
357
+ it("returns undefined when no summary_polyline", () => {
358
+ const result = transformActivity(baseSummaryActivity);
359
+ expect(result.polyline_map_data).toBeUndefined();
360
+ });
361
+ });
362
+
363
+ describe("energy_data", () => {
364
+ it("maps kilojoules", () => {
365
+ const result = transformActivity(baseDetailedActivity);
366
+ expect(result.energy_data?.energy_kilojoules).toBe(780.5);
367
+ });
368
+ });
369
+
370
+ describe("device_data", () => {
371
+ it("maps device_name", () => {
372
+ const result = transformActivity(baseDetailedActivity);
373
+ expect(result.device_data?.name).toBe("Garmin Edge 1030");
374
+ });
375
+ });
376
+
377
+ describe("lap_data", () => {
378
+ it("maps laps from explicit laps array", () => {
379
+ const laps: Lap[] = [
380
+ {
381
+ id: 4479306946,
382
+ resource_state: 2,
383
+ name: "Lap 1",
384
+ activity: { id: 1410355832, resource_state: 1 },
385
+ athlete: { id: 134815, resource_state: 1 },
386
+ elapsed_time: 1573,
387
+ moving_time: 1569,
388
+ start_date: "2018-02-16T14:52:54Z",
389
+ start_date_local: "2018-02-16T06:52:54Z",
390
+ distance: 8046.72,
391
+ start_index: 0,
392
+ end_index: 1570,
393
+ total_elevation_gain: 276,
394
+ average_speed: 5.12,
395
+ max_speed: 9.5,
396
+ average_cadence: 78.6,
397
+ device_watts: true,
398
+ average_watts: 233.1,
399
+ lap_index: 1,
400
+ split: 1,
401
+ },
402
+ ];
403
+
404
+ const result = transformActivity(baseSummaryActivity, { laps });
405
+ expect(result.lap_data?.laps).toHaveLength(1);
406
+ expect(result.lap_data?.laps?.[0].distance_meters).toBe(8046.72);
407
+ expect(result.lap_data?.laps?.[0].avg_speed_meters_per_second).toBe(
408
+ 5.12,
409
+ );
410
+ expect(result.lap_data?.laps?.[0].start_time).toBe(
411
+ "2018-02-16T14:52:54Z",
412
+ );
413
+ });
414
+ });
415
+ });
@@ -0,0 +1,276 @@
1
+ // ─── Activity Transformer ────────────────────────────────────────────────────
2
+ // Transforms a Strava DetailedActivity (+ optional streams/laps) into the
3
+ // Soma Activity schema shape.
4
+
5
+ import type { DetailedActivity, SummaryActivity, StreamSet, Lap } from "./types.js";
6
+ import { mapSportType } from "./maps/sport-type.js";
7
+
8
+ /**
9
+ * The output shape of {@link transformActivity}, matching the Soma Activity
10
+ * validator minus `connectionId` and `userId` (added at ingestion time).
11
+ */
12
+ export type ActivityData = ReturnType<typeof transformActivity>;
13
+
14
+ /**
15
+ * Transform a Strava activity into a Soma Activity document shape.
16
+ *
17
+ * The returned object is ready to be spread into an `ingestActivity` call
18
+ * alongside `connectionId` and `userId`.
19
+ *
20
+ * Accepts either a DetailedActivity (from `GET /activities/{id}`) or a
21
+ * SummaryActivity (from `GET /athlete/activities`). When a DetailedActivity
22
+ * is provided, additional fields like `calories`, `segment_efforts`, and
23
+ * embedded `laps` are mapped. Optional streams and laps can also be supplied
24
+ * for time-series data (heart rate, power, position, etc.).
25
+ *
26
+ * @param activity - The Strava activity (summary or detailed)
27
+ * @param opts - Optional streams and laps data
28
+ * @returns Soma Activity fields (without connectionId/userId)
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * const data = transformActivity(stravaActivity, { streams, laps });
33
+ * await soma.ingestActivity(ctx, { connectionId, userId, ...data });
34
+ * ```
35
+ */
36
+ export function transformActivity(
37
+ activity: DetailedActivity | SummaryActivity,
38
+ opts?: { streams?: StreamSet; laps?: Lap[] },
39
+ ) {
40
+ const streams = opts?.streams;
41
+ const laps = opts?.laps ?? (isDetailed(activity) ? activity.laps : undefined);
42
+ const timeStream = streams?.time?.data;
43
+ const startDate = activity.start_date;
44
+
45
+ return {
46
+ metadata: {
47
+ summary_id: String(activity.id),
48
+ start_time: activity.start_date,
49
+ end_time: computeEndTime(activity.start_date, activity.elapsed_time),
50
+ type: mapSportType(activity.sport_type),
51
+ upload_type: activity.manual ? 2 : 1, // 2=Manual, 1=Automatic
52
+ name: activity.name,
53
+ city: activity.location_city ?? undefined,
54
+ state: activity.location_state ?? undefined,
55
+ country: activity.location_country ?? undefined,
56
+ },
57
+
58
+ active_durations_data: {
59
+ activity_seconds: activity.moving_time,
60
+ },
61
+
62
+ calories_data: isDetailed(activity) && activity.calories != null
63
+ ? { total_burned_calories: activity.calories }
64
+ : undefined,
65
+
66
+ device_data: activity.device_name
67
+ ? { name: activity.device_name }
68
+ : undefined,
69
+
70
+ distance_data: buildDistanceData(activity),
71
+
72
+ energy_data: activity.kilojoules != null
73
+ ? { energy_kilojoules: activity.kilojoules }
74
+ : undefined,
75
+
76
+ heart_rate_data: buildHeartRateData(activity, streams, timeStream, startDate),
77
+
78
+ lap_data: buildLapData(laps),
79
+
80
+ movement_data: buildMovementData(activity, streams, timeStream, startDate),
81
+
82
+ polyline_map_data: activity.map?.summary_polyline
83
+ ? { summary_polyline: activity.map.summary_polyline }
84
+ : undefined,
85
+
86
+ position_data: buildPositionData(activity, streams, timeStream, startDate),
87
+
88
+ power_data: buildPowerData(activity, streams, timeStream, startDate),
89
+ };
90
+ }
91
+
92
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
93
+
94
+ function isDetailed(
95
+ activity: DetailedActivity | SummaryActivity,
96
+ ): activity is DetailedActivity {
97
+ return activity.resource_state === 3;
98
+ }
99
+
100
+ function computeEndTime(startDate: string, elapsedTimeSeconds: number): string {
101
+ const start = new Date(startDate);
102
+ return new Date(start.getTime() + elapsedTimeSeconds * 1000).toISOString();
103
+ }
104
+
105
+ /**
106
+ * Compute an ISO-8601 timestamp for a stream data point, given the
107
+ * activity start time and the time stream offset in seconds.
108
+ */
109
+ function streamTimestamp(
110
+ startDate: string,
111
+ timeOffsetSeconds: number,
112
+ ): string {
113
+ const start = new Date(startDate);
114
+ return new Date(start.getTime() + timeOffsetSeconds * 1000).toISOString();
115
+ }
116
+
117
+ function buildDistanceData(activity: DetailedActivity | SummaryActivity) {
118
+ if (activity.distance == null && activity.total_elevation_gain == null) {
119
+ return undefined;
120
+ }
121
+
122
+ const detailed = isDetailed(activity) ? activity : undefined;
123
+
124
+ return {
125
+ summary: {
126
+ distance_meters: activity.distance ?? undefined,
127
+ elevation:
128
+ activity.total_elevation_gain != null
129
+ ? {
130
+ gain_actual_meters: activity.total_elevation_gain,
131
+ max_meters: detailed?.elev_high ?? undefined,
132
+ min_meters: detailed?.elev_low ?? undefined,
133
+ }
134
+ : undefined,
135
+ },
136
+ };
137
+ }
138
+
139
+ function buildHeartRateData(
140
+ activity: DetailedActivity | SummaryActivity,
141
+ streams: StreamSet | undefined,
142
+ timeStream: number[] | undefined,
143
+ startDate: string,
144
+ ) {
145
+ const hasHrSummary =
146
+ activity.average_heartrate != null || activity.max_heartrate != null;
147
+ const hrStream = streams?.heartrate?.data;
148
+ const hasHrStream = hrStream && hrStream.length > 0 && timeStream;
149
+
150
+ if (!hasHrSummary && !hasHrStream) return undefined;
151
+
152
+ return {
153
+ summary: hasHrSummary
154
+ ? {
155
+ avg_hr_bpm: activity.average_heartrate,
156
+ max_hr_bpm: activity.max_heartrate,
157
+ }
158
+ : undefined,
159
+ detailed:
160
+ hasHrStream && timeStream
161
+ ? {
162
+ hr_samples: hrStream.map((bpm, i) => ({
163
+ timestamp: streamTimestamp(startDate, timeStream[i]),
164
+ bpm,
165
+ })),
166
+ }
167
+ : undefined,
168
+ };
169
+ }
170
+
171
+ function buildLapData(laps: Lap[] | undefined) {
172
+ if (!laps || laps.length === 0) return undefined;
173
+
174
+ return {
175
+ laps: laps.map((lap) => ({
176
+ start_time: lap.start_date,
177
+ end_time: computeEndTime(lap.start_date, lap.elapsed_time),
178
+ distance_meters: lap.distance,
179
+ calories: undefined as number | undefined,
180
+ avg_speed_meters_per_second: lap.average_speed,
181
+ avg_hr_bpm: lap.average_heartrate,
182
+ })),
183
+ };
184
+ }
185
+
186
+ function buildMovementData(
187
+ activity: DetailedActivity | SummaryActivity,
188
+ streams: StreamSet | undefined,
189
+ timeStream: number[] | undefined,
190
+ startDate: string,
191
+ ) {
192
+ const hasMovement =
193
+ activity.average_speed != null ||
194
+ activity.max_speed != null ||
195
+ activity.average_cadence != null;
196
+ const speedStream = streams?.velocity_smooth?.data;
197
+ const cadenceStream = streams?.cadence?.data;
198
+ const hasStreams =
199
+ ((speedStream && speedStream.length > 0) ||
200
+ (cadenceStream && cadenceStream.length > 0)) &&
201
+ timeStream;
202
+
203
+ if (!hasMovement && !hasStreams) return undefined;
204
+
205
+ return {
206
+ avg_speed_meters_per_second: activity.average_speed ?? undefined,
207
+ max_speed_meters_per_second: activity.max_speed ?? undefined,
208
+ avg_cadence_rpm: activity.average_cadence ?? undefined,
209
+ speed_samples:
210
+ speedStream && timeStream
211
+ ? speedStream.map((speed, i) => ({
212
+ timestamp: streamTimestamp(startDate, timeStream[i]),
213
+ speed_meters_per_second: speed,
214
+ }))
215
+ : undefined,
216
+ cadence_samples:
217
+ cadenceStream && timeStream
218
+ ? cadenceStream.map((cadence, i) => ({
219
+ timestamp: streamTimestamp(startDate, timeStream[i]),
220
+ cadence_rpm: cadence,
221
+ }))
222
+ : undefined,
223
+ };
224
+ }
225
+
226
+ function buildPositionData(
227
+ activity: DetailedActivity | SummaryActivity,
228
+ streams: StreamSet | undefined,
229
+ timeStream: number[] | undefined,
230
+ startDate: string,
231
+ ) {
232
+ const latlngStream = streams?.latlng?.data;
233
+ const hasPositionStream = latlngStream && latlngStream.length > 0 && timeStream;
234
+ const hasStartEnd =
235
+ activity.start_latlng != null || activity.end_latlng != null;
236
+
237
+ if (!hasPositionStream && !hasStartEnd) return undefined;
238
+
239
+ return {
240
+ start_pos_lat_lng_deg: activity.start_latlng ?? undefined,
241
+ end_pos_lat_lng_deg: activity.end_latlng ?? undefined,
242
+ position_samples:
243
+ hasPositionStream && timeStream
244
+ ? latlngStream.map((coords, i) => ({
245
+ timestamp: streamTimestamp(startDate, timeStream[i]),
246
+ coords_lat_lng_deg: coords,
247
+ }))
248
+ : undefined,
249
+ };
250
+ }
251
+
252
+ function buildPowerData(
253
+ activity: DetailedActivity | SummaryActivity,
254
+ streams: StreamSet | undefined,
255
+ timeStream: number[] | undefined,
256
+ startDate: string,
257
+ ) {
258
+ const hasPowerSummary =
259
+ activity.average_watts != null || activity.max_watts != null;
260
+ const wattsStream = streams?.watts?.data;
261
+ const hasWattsStream = wattsStream && wattsStream.length > 0 && timeStream;
262
+
263
+ if (!hasPowerSummary && !hasWattsStream) return undefined;
264
+
265
+ return {
266
+ avg_watts: activity.average_watts ?? undefined,
267
+ max_watts: activity.max_watts ?? undefined,
268
+ power_samples:
269
+ hasWattsStream && timeStream
270
+ ? wattsStream.map((watts, i) => ({
271
+ timestamp: streamTimestamp(startDate, timeStream[i]),
272
+ watts,
273
+ }))
274
+ : undefined,
275
+ };
276
+ }