@nativesquare/soma 0.3.0 → 0.5.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 +283 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +328 -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 +77 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +164 -0
- package/dist/component/garmin.d.ts.map +1 -0
- package/dist/component/garmin.js +609 -0
- package/dist/component/garmin.js.map +1 -0
- package/dist/component/public.d.ts +761 -761
- package/dist/component/schema.d.ts +405 -388
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +14 -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 +446 -1
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +89 -0
- package/src/component/garmin.ts +711 -0
- package/src/component/schema.ts +15 -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
package/src/component/schema.ts
CHANGED
|
@@ -121,7 +121,20 @@ export default defineSchema({
|
|
|
121
121
|
providerTokens: defineTable({
|
|
122
122
|
connectionId: v.id("connections"),
|
|
123
123
|
accessToken: v.string(),
|
|
124
|
-
refreshToken: v.string(),
|
|
125
|
-
|
|
124
|
+
refreshToken: v.optional(v.string()), // OAuth 2.0 providers (Strava)
|
|
125
|
+
tokenSecret: v.optional(v.string()), // OAuth 1.0a providers (Garmin)
|
|
126
|
+
expiresAt: v.optional(v.number()), // Unix epoch seconds; absent for permanent tokens
|
|
126
127
|
}).index("by_connectionId", ["connectionId"]),
|
|
128
|
+
|
|
129
|
+
// ── Pending OAuth ─────────────────────────────────────────────────────────
|
|
130
|
+
// Temporary storage for in-progress OAuth flows. Bridges the gap between
|
|
131
|
+
// initiating OAuth (Step 1) and the callback (Step 3) for providers like
|
|
132
|
+
// Garmin that use OAuth 1.0a and don't have a `state` parameter.
|
|
133
|
+
pendingOAuth: defineTable({
|
|
134
|
+
provider: v.string(),
|
|
135
|
+
oauthToken: v.string(),
|
|
136
|
+
tokenSecret: v.string(),
|
|
137
|
+
userId: v.string(),
|
|
138
|
+
createdAt: v.number(),
|
|
139
|
+
}).index("by_oauthToken", ["oauthToken"]),
|
|
127
140
|
});
|
package/src/component/strava.ts
CHANGED
|
@@ -40,6 +40,7 @@ export const storeTokens = internalMutation({
|
|
|
40
40
|
refreshToken: v.string(),
|
|
41
41
|
expiresAt: v.number(),
|
|
42
42
|
},
|
|
43
|
+
returns: v.null(),
|
|
43
44
|
handler: async (ctx, args) => {
|
|
44
45
|
const existing = await ctx.db
|
|
45
46
|
.query("providerTokens")
|
|
@@ -54,10 +55,11 @@ export const storeTokens = internalMutation({
|
|
|
54
55
|
refreshToken: args.refreshToken,
|
|
55
56
|
expiresAt: args.expiresAt,
|
|
56
57
|
});
|
|
57
|
-
return;
|
|
58
|
+
return null;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
await ctx.db.insert("providerTokens", args);
|
|
62
|
+
return null;
|
|
61
63
|
},
|
|
62
64
|
});
|
|
63
65
|
|
|
@@ -66,6 +68,18 @@ export const storeTokens = internalMutation({
|
|
|
66
68
|
*/
|
|
67
69
|
export const getTokens = internalQuery({
|
|
68
70
|
args: { connectionId: v.id("connections") },
|
|
71
|
+
returns: v.union(
|
|
72
|
+
v.object({
|
|
73
|
+
_id: v.id("providerTokens"),
|
|
74
|
+
_creationTime: v.number(),
|
|
75
|
+
connectionId: v.id("connections"),
|
|
76
|
+
accessToken: v.string(),
|
|
77
|
+
refreshToken: v.optional(v.string()),
|
|
78
|
+
tokenSecret: v.optional(v.string()),
|
|
79
|
+
expiresAt: v.optional(v.number()),
|
|
80
|
+
}),
|
|
81
|
+
v.null(),
|
|
82
|
+
),
|
|
69
83
|
handler: async (ctx, args) => {
|
|
70
84
|
return await ctx.db
|
|
71
85
|
.query("providerTokens")
|
|
@@ -81,6 +95,7 @@ export const getTokens = internalQuery({
|
|
|
81
95
|
*/
|
|
82
96
|
export const deleteTokens = internalMutation({
|
|
83
97
|
args: { connectionId: v.id("connections") },
|
|
98
|
+
returns: v.null(),
|
|
84
99
|
handler: async (ctx, args) => {
|
|
85
100
|
const existing = await ctx.db
|
|
86
101
|
.query("providerTokens")
|
|
@@ -92,6 +107,7 @@ export const deleteTokens = internalMutation({
|
|
|
92
107
|
if (existing) {
|
|
93
108
|
await ctx.db.delete(existing._id);
|
|
94
109
|
}
|
|
110
|
+
return null;
|
|
95
111
|
},
|
|
96
112
|
});
|
|
97
113
|
|
|
@@ -252,6 +268,12 @@ export const syncStrava = action({
|
|
|
252
268
|
// 3. Auto-refresh if token is expired or expiring within 5 minutes
|
|
253
269
|
let accessToken = tokenDoc.accessToken;
|
|
254
270
|
const now = Math.floor(Date.now() / 1000);
|
|
271
|
+
if (!tokenDoc.refreshToken || !tokenDoc.expiresAt) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
"Strava tokens are missing refreshToken or expiresAt. " +
|
|
274
|
+
"This connection may have been created with an incompatible version.",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
255
277
|
if (tokenDoc.expiresAt < now + 300) {
|
|
256
278
|
const refreshed = await refreshStravaToken({
|
|
257
279
|
clientId: args.clientId,
|
|
@@ -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
|
+
});
|