@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.
- package/README.md +260 -19
- package/dist/client/index.d.ts +158 -4
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +165 -3
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +37 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/public.d.ts +3 -3
- package/dist/component/schema.d.ts +18 -5
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +10 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/strava.d.ts +88 -0
- package/dist/component/strava.d.ts.map +1 -0
- package/dist/component/strava.js +318 -0
- package/dist/component/strava.js.map +1 -0
- package/dist/component/validators/activity.d.ts +4 -4
- package/dist/component/validators/samples.d.ts +2 -2
- package/dist/strava/activity.d.ts +121 -0
- package/dist/strava/activity.d.ts.map +1 -0
- package/dist/strava/activity.js +201 -0
- package/dist/strava/activity.js.map +1 -0
- package/dist/strava/athlete.d.ts +34 -0
- package/dist/strava/athlete.d.ts.map +1 -0
- package/dist/strava/athlete.js +39 -0
- package/dist/strava/athlete.js.map +1 -0
- package/dist/strava/auth.d.ts +103 -0
- package/dist/strava/auth.d.ts.map +1 -0
- package/dist/strava/auth.js +111 -0
- package/dist/strava/auth.js.map +1 -0
- package/dist/strava/client.d.ts +93 -0
- package/dist/strava/client.d.ts.map +1 -0
- package/dist/strava/client.js +158 -0
- package/dist/strava/client.js.map +1 -0
- package/dist/strava/index.d.ts +13 -0
- package/dist/strava/index.d.ts.map +1 -0
- package/dist/strava/index.js +17 -0
- package/dist/strava/index.js.map +1 -0
- package/dist/strava/maps/sport-type.d.ts +7 -0
- package/dist/strava/maps/sport-type.d.ts.map +1 -0
- package/dist/strava/maps/sport-type.js +84 -0
- package/dist/strava/maps/sport-type.js.map +1 -0
- package/dist/strava/sync.d.ts +104 -0
- package/dist/strava/sync.d.ts.map +1 -0
- package/dist/strava/sync.js +87 -0
- package/dist/strava/sync.js.map +1 -0
- package/dist/strava/types.d.ts +266 -0
- package/dist/strava/types.d.ts.map +1 -0
- package/dist/strava/types.js +8 -0
- package/dist/strava/types.js.map +1 -0
- package/package.json +5 -1
- package/src/client/index.ts +212 -4
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +49 -0
- package/src/component/schema.ts +11 -0
- package/src/component/strava.ts +383 -0
- package/src/strava/activity.test.ts +415 -0
- package/src/strava/activity.ts +276 -0
- package/src/strava/athlete.test.ts +139 -0
- package/src/strava/athlete.ts +47 -0
- package/src/strava/auth.test.ts +78 -0
- package/src/strava/auth.ts +185 -0
- package/src/strava/client.ts +212 -0
- package/src/strava/index.ts +54 -0
- package/src/strava/maps/sport-type.test.ts +69 -0
- package/src/strava/maps/sport-type.ts +99 -0
- package/src/strava/sync.ts +168 -0
- 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
|
+
}
|