@nativesquare/soma 0.7.3 → 0.8.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 (134) hide show
  1. package/dist/client/index.d.ts +83 -0
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +131 -0
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +159 -0
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/garmin.d.ts +190 -6
  8. package/dist/component/garmin.d.ts.map +1 -1
  9. package/dist/component/garmin.js +805 -25
  10. package/dist/component/garmin.js.map +1 -1
  11. package/dist/component/private.d.ts +18 -0
  12. package/dist/component/private.d.ts.map +1 -1
  13. package/dist/component/private.js +18 -0
  14. package/dist/component/private.js.map +1 -1
  15. package/dist/component/public.d.ts +88 -42
  16. package/dist/component/public.d.ts.map +1 -1
  17. package/dist/component/public.js +12 -2
  18. package/dist/component/public.js.map +1 -1
  19. package/dist/component/schema.d.ts +87 -32
  20. package/dist/component/schema.d.ts.map +1 -1
  21. package/dist/component/schema.js +2 -1
  22. package/dist/component/schema.js.map +1 -1
  23. package/dist/component/validators/connection.d.ts +1 -0
  24. package/dist/component/validators/connection.d.ts.map +1 -1
  25. package/dist/component/validators/connection.js +2 -0
  26. package/dist/component/validators/connection.js.map +1 -1
  27. package/dist/component/validators/daily.d.ts +40 -5
  28. package/dist/component/validators/daily.d.ts.map +1 -1
  29. package/dist/component/validators/daily.js +10 -1
  30. package/dist/component/validators/daily.js.map +1 -1
  31. package/dist/component/validators/enums.d.ts +1 -1
  32. package/dist/component/validators/plannedWorkout.d.ts +5 -1
  33. package/dist/component/validators/plannedWorkout.d.ts.map +1 -1
  34. package/dist/component/validators/plannedWorkout.js +4 -0
  35. package/dist/component/validators/plannedWorkout.js.map +1 -1
  36. package/dist/component/validators/sleep.d.ts +8 -8
  37. package/dist/garmin/activity.d.ts +7 -16
  38. package/dist/garmin/activity.d.ts.map +1 -1
  39. package/dist/garmin/activity.js +17 -23
  40. package/dist/garmin/activity.js.map +1 -1
  41. package/dist/garmin/bloodPressure.d.ts +28 -0
  42. package/dist/garmin/bloodPressure.d.ts.map +1 -0
  43. package/dist/garmin/bloodPressure.js +34 -0
  44. package/dist/garmin/bloodPressure.js.map +1 -0
  45. package/dist/garmin/body.js +1 -1
  46. package/dist/garmin/body.js.map +1 -1
  47. package/dist/garmin/client.d.ts +117 -17
  48. package/dist/garmin/client.d.ts.map +1 -1
  49. package/dist/garmin/client.js +337 -43
  50. package/dist/garmin/client.js.map +1 -1
  51. package/dist/garmin/daily.d.ts.map +1 -1
  52. package/dist/garmin/daily.js +3 -3
  53. package/dist/garmin/daily.js.map +1 -1
  54. package/dist/garmin/hrv.d.ts +30 -0
  55. package/dist/garmin/hrv.d.ts.map +1 -0
  56. package/dist/garmin/hrv.js +45 -0
  57. package/dist/garmin/hrv.js.map +1 -0
  58. package/dist/garmin/index.d.ts +16 -2
  59. package/dist/garmin/index.d.ts.map +1 -1
  60. package/dist/garmin/index.js +8 -1
  61. package/dist/garmin/index.js.map +1 -1
  62. package/dist/garmin/maps/activity-type.d.ts +1 -2
  63. package/dist/garmin/maps/activity-type.d.ts.map +1 -1
  64. package/dist/garmin/maps/activity-type.js +1 -0
  65. package/dist/garmin/maps/activity-type.js.map +1 -1
  66. package/dist/garmin/menstruation.d.ts +6 -4
  67. package/dist/garmin/menstruation.d.ts.map +1 -1
  68. package/dist/garmin/menstruation.js +12 -8
  69. package/dist/garmin/menstruation.js.map +1 -1
  70. package/dist/garmin/pulseOx.d.ts +24 -0
  71. package/dist/garmin/pulseOx.d.ts.map +1 -0
  72. package/dist/garmin/pulseOx.js +33 -0
  73. package/dist/garmin/pulseOx.js.map +1 -0
  74. package/dist/garmin/respiration.d.ts +29 -0
  75. package/dist/garmin/respiration.d.ts.map +1 -0
  76. package/dist/garmin/respiration.js +42 -0
  77. package/dist/garmin/respiration.js.map +1 -0
  78. package/dist/garmin/skinTemp.d.ts +27 -0
  79. package/dist/garmin/skinTemp.d.ts.map +1 -0
  80. package/dist/garmin/skinTemp.js +35 -0
  81. package/dist/garmin/skinTemp.js.map +1 -0
  82. package/dist/garmin/sleep.d.ts +4 -4
  83. package/dist/garmin/sleep.d.ts.map +1 -1
  84. package/dist/garmin/sleep.js +15 -9
  85. package/dist/garmin/sleep.js.map +1 -1
  86. package/dist/garmin/stressDetails.d.ts +30 -0
  87. package/dist/garmin/stressDetails.d.ts.map +1 -0
  88. package/dist/garmin/stressDetails.js +49 -0
  89. package/dist/garmin/stressDetails.js.map +1 -0
  90. package/dist/garmin/sync.d.ts +14 -0
  91. package/dist/garmin/sync.d.ts.map +1 -1
  92. package/dist/garmin/sync.js +287 -5
  93. package/dist/garmin/sync.js.map +1 -1
  94. package/dist/garmin/types.d.ts +77 -186
  95. package/dist/garmin/types.d.ts.map +1 -1
  96. package/dist/garmin/types.js +4 -2
  97. package/dist/garmin/types.js.map +1 -1
  98. package/dist/garmin/userMetrics.d.ts +23 -0
  99. package/dist/garmin/userMetrics.d.ts.map +1 -0
  100. package/dist/garmin/userMetrics.js +41 -0
  101. package/dist/garmin/userMetrics.js.map +1 -0
  102. package/dist/validators.d.ts +107 -28
  103. package/dist/validators.d.ts.map +1 -1
  104. package/package.json +133 -124
  105. package/src/client/index.ts +199 -0
  106. package/src/component/_generated/component.ts +161 -2
  107. package/src/component/garmin.ts +898 -26
  108. package/src/component/private.ts +21 -0
  109. package/src/component/public.ts +11 -2
  110. package/src/component/schema.ts +2 -1
  111. package/src/component/validators/connection.ts +2 -0
  112. package/src/component/validators/daily.ts +15 -0
  113. package/src/component/validators/plannedWorkout.ts +4 -0
  114. package/src/garmin/activity.test.ts +13 -21
  115. package/src/garmin/activity.ts +38 -45
  116. package/src/garmin/bloodPressure.ts +41 -0
  117. package/src/garmin/body.ts +1 -1
  118. package/src/garmin/client.ts +550 -71
  119. package/src/garmin/daily.ts +8 -4
  120. package/src/garmin/hrv.ts +57 -0
  121. package/src/garmin/index.ts +77 -7
  122. package/src/garmin/maps/activity-type.ts +2 -2
  123. package/src/garmin/menstruation.ts +14 -12
  124. package/src/garmin/pulseOx.ts +45 -0
  125. package/src/garmin/respiration.ts +55 -0
  126. package/src/garmin/skinTemp.ts +42 -0
  127. package/src/garmin/sleep.test.ts +5 -6
  128. package/src/garmin/sleep.ts +22 -16
  129. package/src/garmin/spec/wellness-api.json +1 -0
  130. package/src/garmin/stressDetails.ts +71 -0
  131. package/src/garmin/sync.ts +348 -5
  132. package/src/garmin/types.ts +88 -300
  133. package/src/garmin/userMetrics.ts +50 -0
  134. package/src/garmin/wellness-api.d.ts +5637 -0
@@ -34,6 +34,27 @@ export const getConnectionByProvider = internalQuery({
34
34
  },
35
35
  });
36
36
 
37
+ /**
38
+ * Get a connection by provider's external user ID + provider (internal).
39
+ * Used by webhook handlers to map Garmin's userId to a Soma connection.
40
+ */
41
+ export const getConnectionByProviderUserId = internalQuery({
42
+ args: {
43
+ providerUserId: v.string(),
44
+ provider: v.string(),
45
+ },
46
+ handler: async (ctx, args) => {
47
+ return await ctx.db
48
+ .query("connections")
49
+ .withIndex("by_providerUserId_provider", (q) =>
50
+ q
51
+ .eq("providerUserId", args.providerUserId)
52
+ .eq("provider", args.provider),
53
+ )
54
+ .first();
55
+ },
56
+ });
57
+
37
58
  /**
38
59
  * Update the lastDataUpdate timestamp after a successful sync.
39
60
  */
@@ -17,6 +17,7 @@ const connectionDoc = v.object({
17
17
  _creationTime: v.number(),
18
18
  userId: v.string(),
19
19
  provider: v.string(),
20
+ providerUserId: v.optional(v.string()),
20
21
  active: v.optional(v.boolean()),
21
22
  lastDataUpdate: v.optional(v.string()),
22
23
  });
@@ -33,6 +34,7 @@ export const connect = mutation({
33
34
  args: {
34
35
  userId: v.string(),
35
36
  provider: v.string(),
37
+ providerUserId: v.optional(v.string()),
36
38
  },
37
39
  returns: v.id("connections"),
38
40
  handler: async (ctx, args) => {
@@ -44,8 +46,13 @@ export const connect = mutation({
44
46
  .first();
45
47
 
46
48
  if (existing) {
47
- if (!existing.active) {
48
- await ctx.db.patch(existing._id, { active: true });
49
+ const patch: Record<string, unknown> = {};
50
+ if (!existing.active) patch.active = true;
51
+ if (args.providerUserId && !existing.providerUserId) {
52
+ patch.providerUserId = args.providerUserId;
53
+ }
54
+ if (Object.keys(patch).length > 0) {
55
+ await ctx.db.patch(existing._id, patch);
49
56
  }
50
57
  return existing._id;
51
58
  }
@@ -53,6 +60,7 @@ export const connect = mutation({
53
60
  return await ctx.db.insert("connections", {
54
61
  userId: args.userId,
55
62
  provider: args.provider,
63
+ providerUserId: args.providerUserId,
56
64
  active: true,
57
65
  });
58
66
  },
@@ -142,6 +150,7 @@ export const listConnections = query({
142
150
  export const updateConnection = mutation({
143
151
  args: {
144
152
  connectionId: v.id("connections"),
153
+ providerUserId: v.optional(v.string()),
145
154
  active: v.optional(v.boolean()),
146
155
  lastDataUpdate: v.optional(v.string()),
147
156
  },
@@ -29,7 +29,8 @@ export default defineSchema({
29
29
  connections: defineTable(connectionValidator)
30
30
  .index("by_userId", ["userId"])
31
31
  .index("by_provider", ["provider"])
32
- .index("by_userId_provider", ["userId", "provider"]),
32
+ .index("by_userId_provider", ["userId", "provider"])
33
+ .index("by_providerUserId_provider", ["providerUserId", "provider"]),
33
34
 
34
35
  // ── Athletes ───────────────────────────────────────────────────────────────
35
36
  // User profile/identifying information from the provider.
@@ -9,6 +9,8 @@ export const connectionValidator = {
9
9
  userId: v.string(),
10
10
  // The wearable provider: "FITBIT", "GARMIN", "APPLE", "OURA", etc.
11
11
  provider: v.string(),
12
+ // The provider's external user ID (e.g. Garmin's userId for webhook mapping)
13
+ providerUserId: v.optional(v.string()),
12
14
  // Whether the connection is active
13
15
  active: v.optional(v.boolean()),
14
16
  // ISO-8601 timestamp of last data update
@@ -1,6 +1,7 @@
1
1
  import { v } from "convex/values";
2
2
  import {
3
3
  activityLevelSample,
4
+ breathSample,
4
5
  calorieSample,
5
6
  distanceSample,
6
7
  elevationSample,
@@ -120,6 +121,20 @@ export const dailyValidator = {
120
121
  upload_type: v.number(), // UploadType enum
121
122
  }),
122
123
 
124
+ // ── respiration_data ────────────────────────────────────────────────────
125
+ respiration_data: v.optional(
126
+ v.object({
127
+ breaths_data: v.optional(
128
+ v.object({
129
+ avg_breaths_per_min: v.optional(v.number()),
130
+ max_breaths_per_min: v.optional(v.number()),
131
+ min_breaths_per_min: v.optional(v.number()),
132
+ samples: v.optional(v.array(breathSample)),
133
+ }),
134
+ ),
135
+ }),
136
+ ),
137
+
123
138
  // ── oxygen_data ──────────────────────────────────────────────────────────
124
139
  oxygen_data: v.optional(
125
140
  v.object({
@@ -106,5 +106,9 @@ export const plannedWorkoutValidator = {
106
106
  pool_length_meters: v.optional(v.number()),
107
107
  estimated_calories: v.optional(v.number()),
108
108
  estimated_duration_seconds: v.optional(v.number()),
109
+ // Provider-assigned workout ID (e.g. Garmin workoutId after push)
110
+ provider_workout_id: v.optional(v.string()),
111
+ // Provider-assigned schedule ID (e.g. Garmin scheduleId after push)
112
+ provider_schedule_id: v.optional(v.string()),
109
113
  }),
110
114
  };
@@ -1,10 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { transformActivity } from "./activity.js";
3
- import type { GarminActivity } from "./types.js";
3
+ import type { GarminActivityExtended } from "./types.js";
4
4
 
5
- const baseActivity: GarminActivity = {
5
+ const baseActivity: GarminActivityExtended = {
6
6
  userId: "garmin_user_1",
7
- userAccessToken: "token",
8
7
  summaryId: "summary_12345",
9
8
  activityId: 12345,
10
9
  activityName: "Morning Run",
@@ -38,7 +37,7 @@ describe("transformActivity", () => {
38
37
  });
39
38
 
40
39
  it("maps calories data", () => {
41
- const withCalories: GarminActivity = {
40
+ const withCalories: GarminActivityExtended = {
42
41
  ...baseActivity,
43
42
  activeKilocalories: 450,
44
43
  bmrKilocalories: 75,
@@ -57,7 +56,7 @@ describe("transformActivity", () => {
57
56
  });
58
57
 
59
58
  it("maps distance data", () => {
60
- const withDistance: GarminActivity = {
59
+ const withDistance: GarminActivityExtended = {
61
60
  ...baseActivity,
62
61
  distanceInMeters: 10000,
63
62
  elevationGainInMeters: 150,
@@ -73,7 +72,7 @@ describe("transformActivity", () => {
73
72
  });
74
73
 
75
74
  it("maps heart rate summary", () => {
76
- const withHR: GarminActivity = {
75
+ const withHR: GarminActivityExtended = {
77
76
  ...baseActivity,
78
77
  averageHeartRateInBeatsPerMinute: 155,
79
78
  maxHeartRateInBeatsPerMinute: 185,
@@ -86,7 +85,7 @@ describe("transformActivity", () => {
86
85
  });
87
86
 
88
87
  it("maps movement data with speed and cadence", () => {
89
- const withMovement: GarminActivity = {
88
+ const withMovement: GarminActivityExtended = {
90
89
  ...baseActivity,
91
90
  averageSpeedInMetersPerSecond: 3.5,
92
91
  maxSpeedInMetersPerSecond: 5.2,
@@ -103,7 +102,7 @@ describe("transformActivity", () => {
103
102
  });
104
103
 
105
104
  it("maps device data", () => {
106
- const withDevice: GarminActivity = {
105
+ const withDevice: GarminActivityExtended = {
107
106
  ...baseActivity,
108
107
  deviceName: "Garmin Forerunner 265",
109
108
  };
@@ -114,7 +113,7 @@ describe("transformActivity", () => {
114
113
  });
115
114
 
116
115
  it("maps position data", () => {
117
- const withPosition: GarminActivity = {
116
+ const withPosition: GarminActivityExtended = {
118
117
  ...baseActivity,
119
118
  startingLatitudeInDegree: 37.7749,
120
119
  startingLongitudeInDegree: -122.4194,
@@ -126,7 +125,7 @@ describe("transformActivity", () => {
126
125
  });
127
126
 
128
127
  it("maps power data", () => {
129
- const withPower: GarminActivity = {
128
+ const withPower: GarminActivityExtended = {
130
129
  ...baseActivity,
131
130
  activityType: "CYCLING",
132
131
  averagePowerInWatts: 200,
@@ -140,22 +139,14 @@ describe("transformActivity", () => {
140
139
  });
141
140
 
142
141
  it("maps lap data", () => {
143
- const withLaps: GarminActivity = {
142
+ const withLaps: GarminActivityExtended = {
144
143
  ...baseActivity,
145
144
  laps: [
146
145
  {
147
146
  startTimeInSeconds: 1700000000,
148
- timerDurationInSeconds: 600,
149
- totalDistanceInMeters: 1600,
150
- heartRate: 150,
151
- maxSpeed: 4.5,
152
147
  },
153
148
  {
154
149
  startTimeInSeconds: 1700000600,
155
- timerDurationInSeconds: 600,
156
- totalDistanceInMeters: 1650,
157
- heartRate: 158,
158
- maxSpeed: 4.8,
159
150
  },
160
151
  ],
161
152
  };
@@ -163,8 +154,9 @@ describe("transformActivity", () => {
163
154
 
164
155
  expect(result.lap_data).toBeDefined();
165
156
  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);
157
+ expect(result.lap_data!.laps![0].start_time).toBe(
158
+ new Date(1700000000 * 1000).toISOString(),
159
+ );
168
160
  });
169
161
 
170
162
  it("uses summaryId as summary_id and falls back to activityId", () => {
@@ -1,7 +1,7 @@
1
1
  // ─── Activity Transformer ────────────────────────────────────────────────────
2
2
  // Transforms a Garmin activity into the Soma Activity schema shape.
3
3
 
4
- import type { GarminActivity } from "./types.js";
4
+ import type { GarminActivityExtended, GarminLap, GarminSample } from "./types.js";
5
5
  import { mapActivityType } from "./maps/activity-type.js";
6
6
 
7
7
  export type ActivityData = ReturnType<typeof transformActivity>;
@@ -9,21 +9,16 @@ export type ActivityData = ReturnType<typeof transformActivity>;
9
9
  /**
10
10
  * Transform a Garmin activity into a Soma Activity document shape.
11
11
  *
12
+ * Accepts both activity summaries (from `/rest/activities`) and detailed
13
+ * activities (from `/rest/activityDetails` or webhook payloads) which
14
+ * include laps, samples, and power data.
15
+ *
12
16
  * The returned object is ready to be spread into an `ingestActivity` call
13
17
  * 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
18
  */
24
- export function transformActivity(activity: GarminActivity) {
25
- const startMs = activity.startTimeInSeconds * 1000;
26
- const endMs = startMs + activity.durationInSeconds * 1000;
19
+ export function transformActivity(activity: GarminActivityExtended) {
20
+ const startMs = (activity.startTimeInSeconds ?? 0) * 1000;
21
+ const endMs = startMs + (activity.durationInSeconds ?? 0) * 1000;
27
22
  const startDate = new Date(startMs).toISOString();
28
23
  const endDate = new Date(endMs).toISOString();
29
24
 
@@ -32,7 +27,7 @@ export function transformActivity(activity: GarminActivity) {
32
27
  summary_id: activity.summaryId ?? String(activity.activityId),
33
28
  start_time: startDate,
34
29
  end_time: endDate,
35
- type: mapActivityType(activity.activityType),
30
+ type: mapActivityType(activity.activityType ?? "OTHER"),
36
31
  upload_type: activity.manual ? 2 : 1,
37
32
  name: activity.activityName,
38
33
  },
@@ -63,7 +58,7 @@ export function transformActivity(activity: GarminActivity) {
63
58
 
64
59
  // ─── Helpers ──────────────────────────────────────────────────────────────────
65
60
 
66
- function buildCaloriesData(activity: GarminActivity) {
61
+ function buildCaloriesData(activity: GarminActivityExtended) {
67
62
  if (
68
63
  activity.activeKilocalories == null &&
69
64
  activity.bmrKilocalories == null
@@ -81,8 +76,12 @@ function buildCaloriesData(activity: GarminActivity) {
81
76
  };
82
77
  }
83
78
 
84
- function buildDistanceData(activity: GarminActivity) {
85
- if (activity.distanceInMeters == null && activity.elevationGainInMeters == null) {
79
+ function buildDistanceData(activity: GarminActivityExtended) {
80
+ // Spec uses totalElevationGainInMeters; webhooks may use elevationGainInMeters
81
+ const elevGain = activity.totalElevationGainInMeters ?? activity.elevationGainInMeters;
82
+ const elevLoss = activity.totalElevationLossInMeters ?? activity.elevationLossInMeters;
83
+
84
+ if (activity.distanceInMeters == null && elevGain == null) {
86
85
  return undefined;
87
86
  }
88
87
 
@@ -91,17 +90,17 @@ function buildDistanceData(activity: GarminActivity) {
91
90
  distance_meters: activity.distanceInMeters,
92
91
  steps: activity.steps,
93
92
  elevation:
94
- activity.elevationGainInMeters != null
93
+ elevGain != null
95
94
  ? {
96
- gain_actual_meters: activity.elevationGainInMeters,
97
- loss_actual_meters: activity.elevationLossInMeters,
95
+ gain_actual_meters: elevGain,
96
+ loss_actual_meters: elevLoss,
98
97
  }
99
98
  : undefined,
100
99
  },
101
100
  };
102
101
  }
103
102
 
104
- function buildHeartRateData(activity: GarminActivity) {
103
+ function buildHeartRateData(activity: GarminActivityExtended) {
105
104
  const hasHrSummary =
106
105
  activity.averageHeartRateInBeatsPerMinute != null ||
107
106
  activity.maxHeartRateInBeatsPerMinute != null;
@@ -111,8 +110,8 @@ function buildHeartRateData(activity: GarminActivity) {
111
110
 
112
111
  const hrSamples = hasSamples
113
112
  ? activity.samples!
114
- .filter((s) => s.heartRate != null && s.startTimeInSeconds != null)
115
- .map((s) => ({
113
+ .filter((s: GarminSample) => s.heartRate != null && s.startTimeInSeconds != null)
114
+ .map((s: GarminSample) => ({
116
115
  timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
117
116
  bpm: s.heartRate!,
118
117
  }))
@@ -132,7 +131,7 @@ function buildHeartRateData(activity: GarminActivity) {
132
131
  };
133
132
  }
134
133
 
135
- function buildMovementData(activity: GarminActivity) {
134
+ function buildMovementData(activity: GarminActivityExtended) {
136
135
  const avgCadence =
137
136
  activity.averageRunCadenceInStepsPerMinute ??
138
137
  activity.averageBikeCadenceInRoundsPerMinute;
@@ -152,10 +151,10 @@ function buildMovementData(activity: GarminActivity) {
152
151
  const speedSamples = hasSamples
153
152
  ? activity.samples!
154
153
  .filter(
155
- (s) =>
154
+ (s: GarminSample) =>
156
155
  s.speedMetersPerSecond != null && s.startTimeInSeconds != null,
157
156
  )
158
- .map((s) => ({
157
+ .map((s: GarminSample) => ({
159
158
  timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
160
159
  speed_meters_per_second: s.speedMetersPerSecond!,
161
160
  }))
@@ -164,15 +163,15 @@ function buildMovementData(activity: GarminActivity) {
164
163
  const cadenceSamples = hasSamples
165
164
  ? activity.samples!
166
165
  .filter(
167
- (s) =>
168
- (s.runCadenceInStepsPerMinute != null ||
166
+ (s: GarminSample) =>
167
+ (s.stepsPerMinute != null ||
169
168
  s.bikeCadenceInRPM != null) &&
170
169
  s.startTimeInSeconds != null,
171
170
  )
172
- .map((s) => ({
171
+ .map((s: GarminSample) => ({
173
172
  timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
174
173
  cadence_rpm:
175
- s.runCadenceInStepsPerMinute ?? s.bikeCadenceInRPM ?? 0,
174
+ s.stepsPerMinute ?? s.bikeCadenceInRPM ?? 0,
176
175
  }))
177
176
  : undefined;
178
177
 
@@ -192,7 +191,7 @@ function buildMovementData(activity: GarminActivity) {
192
191
  };
193
192
  }
194
193
 
195
- function buildPositionData(activity: GarminActivity) {
194
+ function buildPositionData(activity: GarminActivityExtended) {
196
195
  const hasStartPos =
197
196
  activity.startingLatitudeInDegree != null &&
198
197
  activity.startingLongitudeInDegree != null;
@@ -203,12 +202,12 @@ function buildPositionData(activity: GarminActivity) {
203
202
  const positionSamples = hasSamples
204
203
  ? activity.samples!
205
204
  .filter(
206
- (s) =>
205
+ (s: GarminSample) =>
207
206
  s.latitudeInDegree != null &&
208
207
  s.longitudeInDegree != null &&
209
208
  s.startTimeInSeconds != null,
210
209
  )
211
- .map((s) => ({
210
+ .map((s: GarminSample) => ({
212
211
  timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
213
212
  coords_lat_lng_deg: [s.latitudeInDegree!, s.longitudeInDegree!],
214
213
  }))
@@ -228,7 +227,7 @@ function buildPositionData(activity: GarminActivity) {
228
227
  };
229
228
  }
230
229
 
231
- function buildPowerData(activity: GarminActivity) {
230
+ function buildPowerData(activity: GarminActivityExtended) {
232
231
  const hasPowerSummary =
233
232
  activity.averagePowerInWatts != null ||
234
233
  activity.maxPowerInWatts != null;
@@ -239,9 +238,9 @@ function buildPowerData(activity: GarminActivity) {
239
238
  const powerSamples = hasSamples
240
239
  ? activity.samples!
241
240
  .filter(
242
- (s) => s.powerInWatts != null && s.startTimeInSeconds != null,
241
+ (s: GarminSample) => s.powerInWatts != null && s.startTimeInSeconds != null,
243
242
  )
244
- .map((s) => ({
243
+ .map((s: GarminSample) => ({
245
244
  timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
246
245
  watts: s.powerInWatts!,
247
246
  }))
@@ -255,18 +254,12 @@ function buildPowerData(activity: GarminActivity) {
255
254
  };
256
255
  }
257
256
 
258
- function buildLapData(activity: GarminActivity) {
257
+ function buildLapData(activity: GarminActivityExtended) {
259
258
  if (!activity.laps || activity.laps.length === 0) return undefined;
260
259
 
261
260
  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,
261
+ laps: activity.laps.map((lap: GarminLap) => ({
262
+ start_time: new Date((lap.startTimeInSeconds ?? 0) * 1000).toISOString(),
270
263
  })),
271
264
  };
272
265
  }
@@ -0,0 +1,41 @@
1
+ // ─── Blood Pressure Transformer ──────────────────────────────────────────────
2
+ // Transforms Garmin blood pressure data into the Soma Body schema shape.
3
+
4
+ import type { GarminBloodPressure } from "./types.js";
5
+
6
+ export type BloodPressureData = ReturnType<typeof transformBloodPressure>;
7
+
8
+ /**
9
+ * Transform a Garmin blood pressure record into a Soma Body document shape.
10
+ *
11
+ * @param bp - The Garmin blood pressure data from the Health API
12
+ * @returns Soma Body fields (without connectionId/userId)
13
+ */
14
+ export function transformBloodPressure(bp: GarminBloodPressure) {
15
+ const measurementMs = (bp.measurementTimeInSeconds ?? 0) * 1000;
16
+ const timestamp = new Date(measurementMs).toISOString();
17
+
18
+ if (bp.systolic == null && bp.diastolic == null) {
19
+ return {
20
+ metadata: { start_time: timestamp, end_time: timestamp },
21
+ blood_pressure_data: undefined,
22
+ };
23
+ }
24
+
25
+ return {
26
+ metadata: {
27
+ start_time: timestamp,
28
+ end_time: timestamp,
29
+ },
30
+
31
+ blood_pressure_data: {
32
+ blood_pressure_samples: [
33
+ {
34
+ timestamp,
35
+ diastolic_bp: bp.diastolic,
36
+ systolic_bp: bp.systolic,
37
+ },
38
+ ],
39
+ },
40
+ };
41
+ }
@@ -12,7 +12,7 @@ export type BodyData = ReturnType<typeof transformBody>;
12
12
  * @returns Soma Body fields (without connectionId/userId)
13
13
  */
14
14
  export function transformBody(body: GarminBodyComposition) {
15
- const measurementMs = body.measurementTimeInSeconds * 1000;
15
+ const measurementMs = (body.measurementTimeInSeconds ?? 0) * 1000;
16
16
  const timestamp = new Date(measurementMs).toISOString();
17
17
 
18
18
  return {