@nativesquare/soma 0.3.0 → 0.4.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/dist/client/index.d.ts +167 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +150 -0
- 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 +56 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +110 -0
- package/dist/component/garmin.d.ts.map +1 -0
- package/dist/component/garmin.js +454 -0
- package/dist/component/garmin.js.map +1 -0
- package/dist/component/public.d.ts +761 -761
- package/dist/component/schema.d.ts +390 -388
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +3 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/strava.d.ts +5 -4
- package/dist/component/strava.d.ts.map +1 -1
- package/dist/component/strava.js +18 -1
- package/dist/component/strava.js.map +1 -1
- package/dist/component/validators/activity.d.ts +42 -42
- package/dist/component/validators/body.d.ts +47 -47
- package/dist/component/validators/daily.d.ts +17 -17
- package/dist/component/validators/plannedWorkout.d.ts +5 -5
- package/dist/component/validators/samples.d.ts +2 -2
- package/dist/component/validators/shared.d.ts +17 -17
- package/dist/component/validators/sleep.d.ts +17 -17
- package/dist/garmin/activity.d.ts +101 -0
- package/dist/garmin/activity.d.ts.map +1 -0
- package/dist/garmin/activity.js +207 -0
- package/dist/garmin/activity.js.map +1 -0
- package/dist/garmin/auth.d.ts +65 -0
- package/dist/garmin/auth.d.ts.map +1 -0
- package/dist/garmin/auth.js +155 -0
- package/dist/garmin/auth.js.map +1 -0
- package/dist/garmin/body.d.ts +26 -0
- package/dist/garmin/body.d.ts.map +1 -0
- package/dist/garmin/body.js +44 -0
- package/dist/garmin/body.js.map +1 -0
- package/dist/garmin/client.d.ts +99 -0
- package/dist/garmin/client.d.ts.map +1 -0
- package/dist/garmin/client.js +153 -0
- package/dist/garmin/client.js.map +1 -0
- package/dist/garmin/daily.d.ts +74 -0
- package/dist/garmin/daily.d.ts.map +1 -0
- package/dist/garmin/daily.js +143 -0
- package/dist/garmin/daily.js.map +1 -0
- package/dist/garmin/index.d.ts +20 -0
- package/dist/garmin/index.d.ts.map +1 -0
- package/dist/garmin/index.js +21 -0
- package/dist/garmin/index.js.map +1 -0
- package/dist/garmin/maps/activity-type.d.ts +7 -0
- package/dist/garmin/maps/activity-type.d.ts.map +1 -0
- package/dist/garmin/maps/activity-type.js +98 -0
- package/dist/garmin/maps/activity-type.js.map +1 -0
- package/dist/garmin/maps/sleep-level.d.ts +6 -0
- package/dist/garmin/maps/sleep-level.d.ts.map +1 -0
- package/dist/garmin/maps/sleep-level.js +21 -0
- package/dist/garmin/maps/sleep-level.js.map +1 -0
- package/dist/garmin/menstruation.d.ts +23 -0
- package/dist/garmin/menstruation.d.ts.map +1 -0
- package/dist/garmin/menstruation.js +34 -0
- package/dist/garmin/menstruation.js.map +1 -0
- package/dist/garmin/sleep.d.ts +62 -0
- package/dist/garmin/sleep.d.ts.map +1 -0
- package/dist/garmin/sleep.js +125 -0
- package/dist/garmin/sleep.js.map +1 -0
- package/dist/garmin/sync.d.ts +39 -0
- package/dist/garmin/sync.d.ts.map +1 -0
- package/dist/garmin/sync.js +175 -0
- package/dist/garmin/sync.js.map +1 -0
- package/dist/garmin/types.d.ts +212 -0
- package/dist/garmin/types.d.ts.map +1 -0
- package/dist/garmin/types.js +8 -0
- package/dist/garmin/types.js.map +1 -0
- package/dist/validators.d.ts +331 -331
- package/package.json +5 -1
- package/src/client/index.ts +194 -1
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +62 -0
- package/src/component/garmin.ts +534 -0
- package/src/component/schema.ts +3 -2
- package/src/component/strava.ts +23 -1
- package/src/garmin/activity.test.ts +178 -0
- package/src/garmin/activity.ts +272 -0
- package/src/garmin/auth.test.ts +128 -0
- package/src/garmin/auth.ts +249 -0
- package/src/garmin/body.ts +59 -0
- package/src/garmin/client.ts +254 -0
- package/src/garmin/daily.ts +211 -0
- package/src/garmin/index.ts +76 -0
- package/src/garmin/maps/activity-type.test.ts +78 -0
- package/src/garmin/maps/activity-type.ts +116 -0
- package/src/garmin/maps/sleep-level.ts +22 -0
- package/src/garmin/menstruation.ts +42 -0
- package/src/garmin/sleep.test.ts +110 -0
- package/src/garmin/sleep.ts +170 -0
- package/src/garmin/sync.ts +223 -0
- package/src/garmin/types.ts +338 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { transformActivity } from "./activity.js";
|
|
3
|
+
import type { GarminActivity } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const baseActivity: GarminActivity = {
|
|
6
|
+
userId: "garmin_user_1",
|
|
7
|
+
userAccessToken: "token",
|
|
8
|
+
summaryId: "summary_12345",
|
|
9
|
+
activityId: 12345,
|
|
10
|
+
activityName: "Morning Run",
|
|
11
|
+
activityType: "RUNNING",
|
|
12
|
+
durationInSeconds: 3600,
|
|
13
|
+
startTimeInSeconds: 1700000000,
|
|
14
|
+
startTimeOffsetInSeconds: -18000,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("transformActivity", () => {
|
|
18
|
+
it("maps metadata correctly", () => {
|
|
19
|
+
const result = transformActivity(baseActivity);
|
|
20
|
+
|
|
21
|
+
expect(result.metadata.summary_id).toBe("summary_12345");
|
|
22
|
+
expect(result.metadata.start_time).toBe("2023-11-14T22:13:20.000Z");
|
|
23
|
+
expect(result.metadata.end_time).toBe("2023-11-14T23:13:20.000Z");
|
|
24
|
+
expect(result.metadata.type).toBe(8); // RUNNING → Terra Running
|
|
25
|
+
expect(result.metadata.upload_type).toBe(1); // Automatic
|
|
26
|
+
expect(result.metadata.name).toBe("Morning Run");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("maps manual upload type", () => {
|
|
30
|
+
const manual = { ...baseActivity, manual: true };
|
|
31
|
+
const result = transformActivity(manual);
|
|
32
|
+
expect(result.metadata.upload_type).toBe(2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("maps duration data", () => {
|
|
36
|
+
const result = transformActivity(baseActivity);
|
|
37
|
+
expect(result.active_durations_data.activity_seconds).toBe(3600);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("maps calories data", () => {
|
|
41
|
+
const withCalories: GarminActivity = {
|
|
42
|
+
...baseActivity,
|
|
43
|
+
activeKilocalories: 450,
|
|
44
|
+
bmrKilocalories: 75,
|
|
45
|
+
};
|
|
46
|
+
const result = transformActivity(withCalories);
|
|
47
|
+
|
|
48
|
+
expect(result.calories_data).toBeDefined();
|
|
49
|
+
expect(result.calories_data!.net_activity_calories).toBe(450);
|
|
50
|
+
expect(result.calories_data!.BMR_calories).toBe(75);
|
|
51
|
+
expect(result.calories_data!.total_burned_calories).toBe(525);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns undefined calories_data when no calorie fields present", () => {
|
|
55
|
+
const result = transformActivity(baseActivity);
|
|
56
|
+
expect(result.calories_data).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("maps distance data", () => {
|
|
60
|
+
const withDistance: GarminActivity = {
|
|
61
|
+
...baseActivity,
|
|
62
|
+
distanceInMeters: 10000,
|
|
63
|
+
elevationGainInMeters: 150,
|
|
64
|
+
elevationLossInMeters: 120,
|
|
65
|
+
steps: 12000,
|
|
66
|
+
};
|
|
67
|
+
const result = transformActivity(withDistance);
|
|
68
|
+
|
|
69
|
+
expect(result.distance_data).toBeDefined();
|
|
70
|
+
expect(result.distance_data!.summary!.distance_meters).toBe(10000);
|
|
71
|
+
expect(result.distance_data!.summary!.steps).toBe(12000);
|
|
72
|
+
expect(result.distance_data!.summary!.elevation!.gain_actual_meters).toBe(150);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("maps heart rate summary", () => {
|
|
76
|
+
const withHR: GarminActivity = {
|
|
77
|
+
...baseActivity,
|
|
78
|
+
averageHeartRateInBeatsPerMinute: 155,
|
|
79
|
+
maxHeartRateInBeatsPerMinute: 185,
|
|
80
|
+
};
|
|
81
|
+
const result = transformActivity(withHR);
|
|
82
|
+
|
|
83
|
+
expect(result.heart_rate_data).toBeDefined();
|
|
84
|
+
expect(result.heart_rate_data!.summary!.avg_hr_bpm).toBe(155);
|
|
85
|
+
expect(result.heart_rate_data!.summary!.max_hr_bpm).toBe(185);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("maps movement data with speed and cadence", () => {
|
|
89
|
+
const withMovement: GarminActivity = {
|
|
90
|
+
...baseActivity,
|
|
91
|
+
averageSpeedInMetersPerSecond: 3.5,
|
|
92
|
+
maxSpeedInMetersPerSecond: 5.2,
|
|
93
|
+
averageRunCadenceInStepsPerMinute: 170,
|
|
94
|
+
maxRunCadenceInStepsPerMinute: 190,
|
|
95
|
+
};
|
|
96
|
+
const result = transformActivity(withMovement);
|
|
97
|
+
|
|
98
|
+
expect(result.movement_data).toBeDefined();
|
|
99
|
+
expect(result.movement_data!.avg_speed_meters_per_second).toBe(3.5);
|
|
100
|
+
expect(result.movement_data!.max_speed_meters_per_second).toBe(5.2);
|
|
101
|
+
expect(result.movement_data!.avg_cadence_rpm).toBe(170);
|
|
102
|
+
expect(result.movement_data!.max_cadence_rpm).toBe(190);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("maps device data", () => {
|
|
106
|
+
const withDevice: GarminActivity = {
|
|
107
|
+
...baseActivity,
|
|
108
|
+
deviceName: "Garmin Forerunner 265",
|
|
109
|
+
};
|
|
110
|
+
const result = transformActivity(withDevice);
|
|
111
|
+
|
|
112
|
+
expect(result.device_data).toBeDefined();
|
|
113
|
+
expect(result.device_data!.name).toBe("Garmin Forerunner 265");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("maps position data", () => {
|
|
117
|
+
const withPosition: GarminActivity = {
|
|
118
|
+
...baseActivity,
|
|
119
|
+
startingLatitudeInDegree: 37.7749,
|
|
120
|
+
startingLongitudeInDegree: -122.4194,
|
|
121
|
+
};
|
|
122
|
+
const result = transformActivity(withPosition);
|
|
123
|
+
|
|
124
|
+
expect(result.position_data).toBeDefined();
|
|
125
|
+
expect(result.position_data!.start_pos_lat_lng_deg).toEqual([37.7749, -122.4194]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("maps power data", () => {
|
|
129
|
+
const withPower: GarminActivity = {
|
|
130
|
+
...baseActivity,
|
|
131
|
+
activityType: "CYCLING",
|
|
132
|
+
averagePowerInWatts: 200,
|
|
133
|
+
maxPowerInWatts: 450,
|
|
134
|
+
};
|
|
135
|
+
const result = transformActivity(withPower);
|
|
136
|
+
|
|
137
|
+
expect(result.power_data).toBeDefined();
|
|
138
|
+
expect(result.power_data!.avg_watts).toBe(200);
|
|
139
|
+
expect(result.power_data!.max_watts).toBe(450);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("maps lap data", () => {
|
|
143
|
+
const withLaps: GarminActivity = {
|
|
144
|
+
...baseActivity,
|
|
145
|
+
laps: [
|
|
146
|
+
{
|
|
147
|
+
startTimeInSeconds: 1700000000,
|
|
148
|
+
timerDurationInSeconds: 600,
|
|
149
|
+
totalDistanceInMeters: 1600,
|
|
150
|
+
heartRate: 150,
|
|
151
|
+
maxSpeed: 4.5,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
startTimeInSeconds: 1700000600,
|
|
155
|
+
timerDurationInSeconds: 600,
|
|
156
|
+
totalDistanceInMeters: 1650,
|
|
157
|
+
heartRate: 158,
|
|
158
|
+
maxSpeed: 4.8,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
const result = transformActivity(withLaps);
|
|
163
|
+
|
|
164
|
+
expect(result.lap_data).toBeDefined();
|
|
165
|
+
expect(result.lap_data!.laps).toHaveLength(2);
|
|
166
|
+
expect(result.lap_data!.laps![0].distance_meters).toBe(1600);
|
|
167
|
+
expect(result.lap_data!.laps![0].avg_hr_bpm).toBe(150);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("uses summaryId as summary_id and falls back to activityId", () => {
|
|
171
|
+
const withoutSummaryId = {
|
|
172
|
+
...baseActivity,
|
|
173
|
+
summaryId: undefined as unknown as string,
|
|
174
|
+
};
|
|
175
|
+
const result = transformActivity(withoutSummaryId);
|
|
176
|
+
expect(result.metadata.summary_id).toBe("12345");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// ─── Activity Transformer ────────────────────────────────────────────────────
|
|
2
|
+
// Transforms a Garmin activity into the Soma Activity schema shape.
|
|
3
|
+
|
|
4
|
+
import type { GarminActivity } from "./types.js";
|
|
5
|
+
import { mapActivityType } from "./maps/activity-type.js";
|
|
6
|
+
|
|
7
|
+
export type ActivityData = ReturnType<typeof transformActivity>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Transform a Garmin activity into a Soma Activity document shape.
|
|
11
|
+
*
|
|
12
|
+
* The returned object is ready to be spread into an `ingestActivity` call
|
|
13
|
+
* alongside `connectionId` and `userId`.
|
|
14
|
+
*
|
|
15
|
+
* @param activity - The Garmin activity from the Health API
|
|
16
|
+
* @returns Soma Activity fields (without connectionId/userId)
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const data = transformActivity(garminActivity);
|
|
21
|
+
* await soma.ingestActivity(ctx, { connectionId, userId, ...data });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function transformActivity(activity: GarminActivity) {
|
|
25
|
+
const startMs = activity.startTimeInSeconds * 1000;
|
|
26
|
+
const endMs = startMs + activity.durationInSeconds * 1000;
|
|
27
|
+
const startDate = new Date(startMs).toISOString();
|
|
28
|
+
const endDate = new Date(endMs).toISOString();
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
metadata: {
|
|
32
|
+
summary_id: activity.summaryId ?? String(activity.activityId),
|
|
33
|
+
start_time: startDate,
|
|
34
|
+
end_time: endDate,
|
|
35
|
+
type: mapActivityType(activity.activityType),
|
|
36
|
+
upload_type: activity.manual ? 2 : 1,
|
|
37
|
+
name: activity.activityName,
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
active_durations_data: {
|
|
41
|
+
activity_seconds: activity.durationInSeconds,
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
calories_data: buildCaloriesData(activity),
|
|
45
|
+
|
|
46
|
+
device_data: activity.deviceName
|
|
47
|
+
? { name: activity.deviceName }
|
|
48
|
+
: undefined,
|
|
49
|
+
|
|
50
|
+
distance_data: buildDistanceData(activity),
|
|
51
|
+
|
|
52
|
+
heart_rate_data: buildHeartRateData(activity),
|
|
53
|
+
|
|
54
|
+
movement_data: buildMovementData(activity),
|
|
55
|
+
|
|
56
|
+
position_data: buildPositionData(activity),
|
|
57
|
+
|
|
58
|
+
power_data: buildPowerData(activity),
|
|
59
|
+
|
|
60
|
+
lap_data: buildLapData(activity),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function buildCaloriesData(activity: GarminActivity) {
|
|
67
|
+
if (
|
|
68
|
+
activity.activeKilocalories == null &&
|
|
69
|
+
activity.bmrKilocalories == null
|
|
70
|
+
) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const total =
|
|
75
|
+
(activity.activeKilocalories ?? 0) + (activity.bmrKilocalories ?? 0);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
net_activity_calories: activity.activeKilocalories,
|
|
79
|
+
BMR_calories: activity.bmrKilocalories,
|
|
80
|
+
total_burned_calories: total || undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildDistanceData(activity: GarminActivity) {
|
|
85
|
+
if (activity.distanceInMeters == null && activity.elevationGainInMeters == null) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
summary: {
|
|
91
|
+
distance_meters: activity.distanceInMeters,
|
|
92
|
+
steps: activity.steps,
|
|
93
|
+
elevation:
|
|
94
|
+
activity.elevationGainInMeters != null
|
|
95
|
+
? {
|
|
96
|
+
gain_actual_meters: activity.elevationGainInMeters,
|
|
97
|
+
loss_actual_meters: activity.elevationLossInMeters,
|
|
98
|
+
}
|
|
99
|
+
: undefined,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildHeartRateData(activity: GarminActivity) {
|
|
105
|
+
const hasHrSummary =
|
|
106
|
+
activity.averageHeartRateInBeatsPerMinute != null ||
|
|
107
|
+
activity.maxHeartRateInBeatsPerMinute != null;
|
|
108
|
+
const hasSamples = activity.samples && activity.samples.length > 0;
|
|
109
|
+
|
|
110
|
+
if (!hasHrSummary && !hasSamples) return undefined;
|
|
111
|
+
|
|
112
|
+
const hrSamples = hasSamples
|
|
113
|
+
? activity.samples!
|
|
114
|
+
.filter((s) => s.heartRate != null && s.startTimeInSeconds != null)
|
|
115
|
+
.map((s) => ({
|
|
116
|
+
timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
|
|
117
|
+
bpm: s.heartRate!,
|
|
118
|
+
}))
|
|
119
|
+
: undefined;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
summary: hasHrSummary
|
|
123
|
+
? {
|
|
124
|
+
avg_hr_bpm: activity.averageHeartRateInBeatsPerMinute,
|
|
125
|
+
max_hr_bpm: activity.maxHeartRateInBeatsPerMinute,
|
|
126
|
+
}
|
|
127
|
+
: undefined,
|
|
128
|
+
detailed:
|
|
129
|
+
hrSamples && hrSamples.length > 0
|
|
130
|
+
? { hr_samples: hrSamples }
|
|
131
|
+
: undefined,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildMovementData(activity: GarminActivity) {
|
|
136
|
+
const avgCadence =
|
|
137
|
+
activity.averageRunCadenceInStepsPerMinute ??
|
|
138
|
+
activity.averageBikeCadenceInRoundsPerMinute;
|
|
139
|
+
const maxCadence =
|
|
140
|
+
activity.maxRunCadenceInStepsPerMinute ??
|
|
141
|
+
activity.maxBikeCadenceInRoundsPerMinute;
|
|
142
|
+
|
|
143
|
+
const hasMovement =
|
|
144
|
+
activity.averageSpeedInMetersPerSecond != null ||
|
|
145
|
+
activity.maxSpeedInMetersPerSecond != null ||
|
|
146
|
+
avgCadence != null;
|
|
147
|
+
|
|
148
|
+
const hasSamples = activity.samples && activity.samples.length > 0;
|
|
149
|
+
|
|
150
|
+
if (!hasMovement && !hasSamples) return undefined;
|
|
151
|
+
|
|
152
|
+
const speedSamples = hasSamples
|
|
153
|
+
? activity.samples!
|
|
154
|
+
.filter(
|
|
155
|
+
(s) =>
|
|
156
|
+
s.speedMetersPerSecond != null && s.startTimeInSeconds != null,
|
|
157
|
+
)
|
|
158
|
+
.map((s) => ({
|
|
159
|
+
timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
|
|
160
|
+
speed_meters_per_second: s.speedMetersPerSecond!,
|
|
161
|
+
}))
|
|
162
|
+
: undefined;
|
|
163
|
+
|
|
164
|
+
const cadenceSamples = hasSamples
|
|
165
|
+
? activity.samples!
|
|
166
|
+
.filter(
|
|
167
|
+
(s) =>
|
|
168
|
+
(s.runCadenceInStepsPerMinute != null ||
|
|
169
|
+
s.bikeCadenceInRPM != null) &&
|
|
170
|
+
s.startTimeInSeconds != null,
|
|
171
|
+
)
|
|
172
|
+
.map((s) => ({
|
|
173
|
+
timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
|
|
174
|
+
cadence_rpm:
|
|
175
|
+
s.runCadenceInStepsPerMinute ?? s.bikeCadenceInRPM ?? 0,
|
|
176
|
+
}))
|
|
177
|
+
: undefined;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
avg_speed_meters_per_second: activity.averageSpeedInMetersPerSecond,
|
|
181
|
+
max_speed_meters_per_second: activity.maxSpeedInMetersPerSecond,
|
|
182
|
+
avg_pace_minutes_per_kilometer: activity.averagePaceInMinutesPerKilometer,
|
|
183
|
+
max_pace_minutes_per_kilometer: activity.maxPaceInMinutesPerKilometer,
|
|
184
|
+
avg_cadence_rpm: avgCadence,
|
|
185
|
+
max_cadence_rpm: maxCadence,
|
|
186
|
+
speed_samples:
|
|
187
|
+
speedSamples && speedSamples.length > 0 ? speedSamples : undefined,
|
|
188
|
+
cadence_samples:
|
|
189
|
+
cadenceSamples && cadenceSamples.length > 0
|
|
190
|
+
? cadenceSamples
|
|
191
|
+
: undefined,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildPositionData(activity: GarminActivity) {
|
|
196
|
+
const hasStartPos =
|
|
197
|
+
activity.startingLatitudeInDegree != null &&
|
|
198
|
+
activity.startingLongitudeInDegree != null;
|
|
199
|
+
const hasSamples = activity.samples && activity.samples.length > 0;
|
|
200
|
+
|
|
201
|
+
if (!hasStartPos && !hasSamples) return undefined;
|
|
202
|
+
|
|
203
|
+
const positionSamples = hasSamples
|
|
204
|
+
? activity.samples!
|
|
205
|
+
.filter(
|
|
206
|
+
(s) =>
|
|
207
|
+
s.latitudeInDegree != null &&
|
|
208
|
+
s.longitudeInDegree != null &&
|
|
209
|
+
s.startTimeInSeconds != null,
|
|
210
|
+
)
|
|
211
|
+
.map((s) => ({
|
|
212
|
+
timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
|
|
213
|
+
coords_lat_lng_deg: [s.latitudeInDegree!, s.longitudeInDegree!],
|
|
214
|
+
}))
|
|
215
|
+
: undefined;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
start_pos_lat_lng_deg: hasStartPos
|
|
219
|
+
? [
|
|
220
|
+
activity.startingLatitudeInDegree!,
|
|
221
|
+
activity.startingLongitudeInDegree!,
|
|
222
|
+
]
|
|
223
|
+
: undefined,
|
|
224
|
+
position_samples:
|
|
225
|
+
positionSamples && positionSamples.length > 0
|
|
226
|
+
? positionSamples
|
|
227
|
+
: undefined,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildPowerData(activity: GarminActivity) {
|
|
232
|
+
const hasPowerSummary =
|
|
233
|
+
activity.averagePowerInWatts != null ||
|
|
234
|
+
activity.maxPowerInWatts != null;
|
|
235
|
+
const hasSamples = activity.samples && activity.samples.length > 0;
|
|
236
|
+
|
|
237
|
+
if (!hasPowerSummary && !hasSamples) return undefined;
|
|
238
|
+
|
|
239
|
+
const powerSamples = hasSamples
|
|
240
|
+
? activity.samples!
|
|
241
|
+
.filter(
|
|
242
|
+
(s) => s.powerInWatts != null && s.startTimeInSeconds != null,
|
|
243
|
+
)
|
|
244
|
+
.map((s) => ({
|
|
245
|
+
timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
|
|
246
|
+
watts: s.powerInWatts!,
|
|
247
|
+
}))
|
|
248
|
+
: undefined;
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
avg_watts: activity.averagePowerInWatts,
|
|
252
|
+
max_watts: activity.maxPowerInWatts,
|
|
253
|
+
power_samples:
|
|
254
|
+
powerSamples && powerSamples.length > 0 ? powerSamples : undefined,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildLapData(activity: GarminActivity) {
|
|
259
|
+
if (!activity.laps || activity.laps.length === 0) return undefined;
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
laps: activity.laps.map((lap) => ({
|
|
263
|
+
start_time: new Date(lap.startTimeInSeconds * 1000).toISOString(),
|
|
264
|
+
end_time: new Date(
|
|
265
|
+
(lap.startTimeInSeconds + lap.timerDurationInSeconds) * 1000,
|
|
266
|
+
).toISOString(),
|
|
267
|
+
distance_meters: lap.totalDistanceInMeters,
|
|
268
|
+
avg_hr_bpm: lap.heartRate,
|
|
269
|
+
avg_speed_meters_per_second: lap.maxSpeed,
|
|
270
|
+
})),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildOAuthSignature,
|
|
4
|
+
buildOAuthHeader,
|
|
5
|
+
percentEncode,
|
|
6
|
+
generateNonce,
|
|
7
|
+
getTimestamp,
|
|
8
|
+
} from "./auth.js";
|
|
9
|
+
|
|
10
|
+
describe("percentEncode", () => {
|
|
11
|
+
it("encodes special characters per RFC 3986", () => {
|
|
12
|
+
expect(percentEncode("hello world")).toBe("hello%20world");
|
|
13
|
+
expect(percentEncode("a=b&c=d")).toBe("a%3Db%26c%3Dd");
|
|
14
|
+
expect(percentEncode("test!")).toBe("test%21");
|
|
15
|
+
expect(percentEncode("100%")).toBe("100%25");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("encodes characters that encodeURIComponent misses", () => {
|
|
19
|
+
expect(percentEncode("a'b")).toBe("a%27b");
|
|
20
|
+
expect(percentEncode("a(b)")).toBe("a%28b%29");
|
|
21
|
+
expect(percentEncode("a*b")).toBe("a%2Ab");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("does not encode unreserved characters", () => {
|
|
25
|
+
expect(percentEncode("abcXYZ123")).toBe("abcXYZ123");
|
|
26
|
+
expect(percentEncode("-._~")).toBe("-._~");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("generateNonce", () => {
|
|
31
|
+
it("returns a 32-character hex string", () => {
|
|
32
|
+
const nonce = generateNonce();
|
|
33
|
+
expect(nonce).toHaveLength(32);
|
|
34
|
+
expect(nonce).toMatch(/^[0-9a-f]+$/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("generates unique values", () => {
|
|
38
|
+
const nonce1 = generateNonce();
|
|
39
|
+
const nonce2 = generateNonce();
|
|
40
|
+
expect(nonce1).not.toBe(nonce2);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("getTimestamp", () => {
|
|
45
|
+
it("returns a Unix timestamp string", () => {
|
|
46
|
+
const ts = getTimestamp();
|
|
47
|
+
const parsed = parseInt(ts, 10);
|
|
48
|
+
expect(parsed).toBeGreaterThan(1700000000);
|
|
49
|
+
expect(String(parsed)).toBe(ts);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("buildOAuthSignature", () => {
|
|
54
|
+
it("produces a valid HMAC-SHA1 signature", async () => {
|
|
55
|
+
const signature = await buildOAuthSignature(
|
|
56
|
+
"GET",
|
|
57
|
+
"https://api.example.com/resource",
|
|
58
|
+
{
|
|
59
|
+
oauth_consumer_key: "consumer_key",
|
|
60
|
+
oauth_nonce: "kllo9940pd9333jh",
|
|
61
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
62
|
+
oauth_timestamp: "1191242096",
|
|
63
|
+
oauth_version: "1.0",
|
|
64
|
+
},
|
|
65
|
+
"consumer_secret",
|
|
66
|
+
"",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(signature).toBeTruthy();
|
|
70
|
+
expect(typeof signature).toBe("string");
|
|
71
|
+
expect(signature).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("includes token secret in signing key when provided", async () => {
|
|
75
|
+
const sig1 = await buildOAuthSignature(
|
|
76
|
+
"POST",
|
|
77
|
+
"https://api.example.com/resource",
|
|
78
|
+
{ oauth_consumer_key: "key", oauth_nonce: "abc", oauth_timestamp: "123" },
|
|
79
|
+
"consumer_secret",
|
|
80
|
+
"",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const sig2 = await buildOAuthSignature(
|
|
84
|
+
"POST",
|
|
85
|
+
"https://api.example.com/resource",
|
|
86
|
+
{ oauth_consumer_key: "key", oauth_nonce: "abc", oauth_timestamp: "123" },
|
|
87
|
+
"consumer_secret",
|
|
88
|
+
"token_secret",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(sig1).not.toBe(sig2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("sorts parameters alphabetically", async () => {
|
|
95
|
+
const sig1 = await buildOAuthSignature(
|
|
96
|
+
"GET",
|
|
97
|
+
"https://api.example.com/resource",
|
|
98
|
+
{ z_param: "last", a_param: "first", m_param: "middle" },
|
|
99
|
+
"secret",
|
|
100
|
+
);
|
|
101
|
+
const sig2 = await buildOAuthSignature(
|
|
102
|
+
"GET",
|
|
103
|
+
"https://api.example.com/resource",
|
|
104
|
+
{ a_param: "first", m_param: "middle", z_param: "last" },
|
|
105
|
+
"secret",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(sig1).toBe(sig2);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("buildOAuthHeader", () => {
|
|
113
|
+
it("builds a valid OAuth Authorization header", () => {
|
|
114
|
+
const header = buildOAuthHeader({
|
|
115
|
+
oauth_consumer_key: "my_key",
|
|
116
|
+
oauth_nonce: "abc123",
|
|
117
|
+
oauth_signature: "sig%3D",
|
|
118
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
119
|
+
oauth_timestamp: "1234567890",
|
|
120
|
+
oauth_version: "1.0",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(header).toMatch(/^OAuth /);
|
|
124
|
+
expect(header).toContain('oauth_consumer_key="my_key"');
|
|
125
|
+
expect(header).toContain('oauth_nonce="abc123"');
|
|
126
|
+
expect(header).toContain('oauth_version="1.0"');
|
|
127
|
+
});
|
|
128
|
+
});
|