@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.
Files changed (101) hide show
  1. package/dist/client/index.d.ts +283 -0
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +328 -0
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/api.d.ts +2 -0
  6. package/dist/component/_generated/api.d.ts.map +1 -1
  7. package/dist/component/_generated/api.js.map +1 -1
  8. package/dist/component/_generated/component.d.ts +77 -0
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/garmin.d.ts +164 -0
  11. package/dist/component/garmin.d.ts.map +1 -0
  12. package/dist/component/garmin.js +609 -0
  13. package/dist/component/garmin.js.map +1 -0
  14. package/dist/component/public.d.ts +761 -761
  15. package/dist/component/schema.d.ts +405 -388
  16. package/dist/component/schema.d.ts.map +1 -1
  17. package/dist/component/schema.js +14 -2
  18. package/dist/component/schema.js.map +1 -1
  19. package/dist/component/strava.d.ts +5 -4
  20. package/dist/component/strava.d.ts.map +1 -1
  21. package/dist/component/strava.js +18 -1
  22. package/dist/component/strava.js.map +1 -1
  23. package/dist/component/validators/activity.d.ts +42 -42
  24. package/dist/component/validators/body.d.ts +47 -47
  25. package/dist/component/validators/daily.d.ts +17 -17
  26. package/dist/component/validators/plannedWorkout.d.ts +5 -5
  27. package/dist/component/validators/samples.d.ts +2 -2
  28. package/dist/component/validators/shared.d.ts +17 -17
  29. package/dist/component/validators/sleep.d.ts +17 -17
  30. package/dist/garmin/activity.d.ts +101 -0
  31. package/dist/garmin/activity.d.ts.map +1 -0
  32. package/dist/garmin/activity.js +207 -0
  33. package/dist/garmin/activity.js.map +1 -0
  34. package/dist/garmin/auth.d.ts +65 -0
  35. package/dist/garmin/auth.d.ts.map +1 -0
  36. package/dist/garmin/auth.js +155 -0
  37. package/dist/garmin/auth.js.map +1 -0
  38. package/dist/garmin/body.d.ts +26 -0
  39. package/dist/garmin/body.d.ts.map +1 -0
  40. package/dist/garmin/body.js +44 -0
  41. package/dist/garmin/body.js.map +1 -0
  42. package/dist/garmin/client.d.ts +99 -0
  43. package/dist/garmin/client.d.ts.map +1 -0
  44. package/dist/garmin/client.js +153 -0
  45. package/dist/garmin/client.js.map +1 -0
  46. package/dist/garmin/daily.d.ts +74 -0
  47. package/dist/garmin/daily.d.ts.map +1 -0
  48. package/dist/garmin/daily.js +143 -0
  49. package/dist/garmin/daily.js.map +1 -0
  50. package/dist/garmin/index.d.ts +20 -0
  51. package/dist/garmin/index.d.ts.map +1 -0
  52. package/dist/garmin/index.js +21 -0
  53. package/dist/garmin/index.js.map +1 -0
  54. package/dist/garmin/maps/activity-type.d.ts +7 -0
  55. package/dist/garmin/maps/activity-type.d.ts.map +1 -0
  56. package/dist/garmin/maps/activity-type.js +98 -0
  57. package/dist/garmin/maps/activity-type.js.map +1 -0
  58. package/dist/garmin/maps/sleep-level.d.ts +6 -0
  59. package/dist/garmin/maps/sleep-level.d.ts.map +1 -0
  60. package/dist/garmin/maps/sleep-level.js +21 -0
  61. package/dist/garmin/maps/sleep-level.js.map +1 -0
  62. package/dist/garmin/menstruation.d.ts +23 -0
  63. package/dist/garmin/menstruation.d.ts.map +1 -0
  64. package/dist/garmin/menstruation.js +34 -0
  65. package/dist/garmin/menstruation.js.map +1 -0
  66. package/dist/garmin/sleep.d.ts +62 -0
  67. package/dist/garmin/sleep.d.ts.map +1 -0
  68. package/dist/garmin/sleep.js +125 -0
  69. package/dist/garmin/sleep.js.map +1 -0
  70. package/dist/garmin/sync.d.ts +39 -0
  71. package/dist/garmin/sync.d.ts.map +1 -0
  72. package/dist/garmin/sync.js +175 -0
  73. package/dist/garmin/sync.js.map +1 -0
  74. package/dist/garmin/types.d.ts +212 -0
  75. package/dist/garmin/types.d.ts.map +1 -0
  76. package/dist/garmin/types.js +8 -0
  77. package/dist/garmin/types.js.map +1 -0
  78. package/dist/validators.d.ts +331 -331
  79. package/package.json +5 -1
  80. package/src/client/index.ts +446 -1
  81. package/src/component/_generated/api.ts +2 -0
  82. package/src/component/_generated/component.ts +89 -0
  83. package/src/component/garmin.ts +711 -0
  84. package/src/component/schema.ts +15 -2
  85. package/src/component/strava.ts +23 -1
  86. package/src/garmin/activity.test.ts +178 -0
  87. package/src/garmin/activity.ts +272 -0
  88. package/src/garmin/auth.test.ts +128 -0
  89. package/src/garmin/auth.ts +249 -0
  90. package/src/garmin/body.ts +59 -0
  91. package/src/garmin/client.ts +254 -0
  92. package/src/garmin/daily.ts +211 -0
  93. package/src/garmin/index.ts +76 -0
  94. package/src/garmin/maps/activity-type.test.ts +78 -0
  95. package/src/garmin/maps/activity-type.ts +116 -0
  96. package/src/garmin/maps/sleep-level.ts +22 -0
  97. package/src/garmin/menstruation.ts +42 -0
  98. package/src/garmin/sleep.test.ts +110 -0
  99. package/src/garmin/sleep.ts +170 -0
  100. package/src/garmin/sync.ts +223 -0
  101. package/src/garmin/types.ts +338 -0
@@ -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
- expiresAt: v.number(), // Unix epoch seconds
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
  });
@@ -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
+ });