@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
@@ -3,6 +3,10 @@
3
3
 
4
4
  import type { GarminDailySummary } from "./types.js";
5
5
 
6
+ // GarminDailySummary is an alias for GarminDailyExtended which includes
7
+ // both spec fields and additional fields the API returns (stress time-series,
8
+ // SpO2, body battery, respiration).
9
+
6
10
  export type DailyData = ReturnType<typeof transformDaily>;
7
11
 
8
12
  /**
@@ -12,8 +16,8 @@ export type DailyData = ReturnType<typeof transformDaily>;
12
16
  * @returns Soma Daily fields (without connectionId/userId)
13
17
  */
14
18
  export function transformDaily(daily: GarminDailySummary) {
15
- const startMs = daily.startTimeInSeconds * 1000;
16
- const endMs = startMs + daily.durationInSeconds * 1000;
19
+ const startMs = (daily.startTimeInSeconds ?? 0) * 1000;
20
+ const endMs = startMs + (daily.durationInSeconds ?? 0) * 1000;
17
21
 
18
22
  return {
19
23
  metadata: {
@@ -198,13 +202,13 @@ function buildStressData(daily: GarminDailySummary) {
198
202
  * This converts to `{ timestamp: ISO-8601, ...fields }[]`.
199
203
  */
200
204
  function buildTimeOffsetSamples<T extends Record<string, unknown>>(
201
- startTimeInSeconds: number,
205
+ startTimeInSeconds: number | undefined,
202
206
  offsets: Record<string, number>,
203
207
  mapValue: (value: number) => T,
204
208
  ): Array<{ timestamp: string } & T> {
205
209
  return Object.entries(offsets).map(([offset, value]) => ({
206
210
  timestamp: new Date(
207
- (startTimeInSeconds + parseInt(offset, 10)) * 1000,
211
+ ((startTimeInSeconds ?? 0) + parseInt(offset, 10)) * 1000,
208
212
  ).toISOString(),
209
213
  ...mapValue(value),
210
214
  }));
@@ -0,0 +1,57 @@
1
+ // ─── HRV Transformer ─────────────────────────────────────────────────────────
2
+ // Transforms Garmin HRV summary data into fields that enrich a Soma Daily record.
3
+
4
+ import type { GarminHRVSummary } from "./types.js";
5
+
6
+ export type HRVData = ReturnType<typeof transformHRV>;
7
+
8
+ /**
9
+ * Transform a Garmin HRV summary into heart rate data fields for a Soma Daily.
10
+ *
11
+ * This produces a partial daily shape that can be merged into a matching
12
+ * daily record by date.
13
+ *
14
+ * @param hrv - The Garmin HRV summary from the Health API
15
+ * @returns Partial Soma Daily heart_rate_data fields
16
+ */
17
+ export function transformHRV(hrv: GarminHRVSummary) {
18
+ const startMs = (hrv.startTimeInSeconds ?? 0) * 1000;
19
+ const endMs = startMs + (hrv.durationInSeconds ?? 0) * 1000;
20
+
21
+ const hasSummary = hrv.lastNightAvg != null || hrv.lastNight5MinHigh != null;
22
+ const hasSamples =
23
+ hrv.hrvValues != null && Object.keys(hrv.hrvValues).length > 0;
24
+
25
+ if (!hasSummary && !hasSamples) {
26
+ return {
27
+ calendar_date: hrv.calendarDate,
28
+ heart_rate_data: undefined,
29
+ };
30
+ }
31
+
32
+ const startTime = hrv.startTimeInSeconds ?? 0;
33
+ const hrvSamples = hasSamples
34
+ ? Object.entries(hrv.hrvValues!).map(([offset, rmssd]) => ({
35
+ timestamp: new Date(
36
+ (startTime + parseInt(offset, 10)) * 1000,
37
+ ).toISOString(),
38
+ hrv_rmssd: rmssd,
39
+ }))
40
+ : undefined;
41
+
42
+ return {
43
+ calendar_date: hrv.calendarDate,
44
+ heart_rate_data: {
45
+ summary: hasSummary
46
+ ? {
47
+ avg_hrv_rmssd: hrv.lastNightAvg,
48
+ max_hrv_rmssd: hrv.lastNight5MinHigh,
49
+ }
50
+ : undefined,
51
+ detailed:
52
+ hrvSamples && hrvSamples.length > 0
53
+ ? { hrv_samples_rmssd: hrvSamples }
54
+ : undefined,
55
+ },
56
+ };
57
+ }
@@ -20,6 +20,27 @@ export type { BodyData } from "./body.js";
20
20
  export { transformMenstruation } from "./menstruation.js";
21
21
  export type { MenstruationData } from "./menstruation.js";
22
22
 
23
+ export { transformBloodPressure } from "./bloodPressure.js";
24
+ export type { BloodPressureData } from "./bloodPressure.js";
25
+
26
+ export { transformSkinTemp } from "./skinTemp.js";
27
+ export type { SkinTempData } from "./skinTemp.js";
28
+
29
+ export { transformUserMetrics } from "./userMetrics.js";
30
+ export type { UserMetricsData } from "./userMetrics.js";
31
+
32
+ export { transformHRV } from "./hrv.js";
33
+ export type { HRVData } from "./hrv.js";
34
+
35
+ export { transformStressDetails } from "./stressDetails.js";
36
+ export type { StressDetailsData } from "./stressDetails.js";
37
+
38
+ export { transformPulseOx } from "./pulseOx.js";
39
+ export type { PulseOxData } from "./pulseOx.js";
40
+
41
+ export { transformRespiration } from "./respiration.js";
42
+ export type { RespirationData } from "./respiration.js";
43
+
23
44
  // ── Enum Maps ────────────────────────────────────────────────────────────────
24
45
  export { mapActivityType } from "./maps/activity-type.js";
25
46
  export { mapSleepLevel } from "./maps/sleep-level.js";
@@ -51,6 +72,13 @@ export {
51
72
  syncSleep,
52
73
  syncBody,
53
74
  syncMenstruation,
75
+ syncBloodPressures,
76
+ syncSkinTemp,
77
+ syncUserMetrics,
78
+ syncHRV,
79
+ syncStressDetails,
80
+ syncPulseOx,
81
+ syncRespiration,
54
82
  } from "./sync.js";
55
83
  export type {
56
84
  SyncOptions,
@@ -60,16 +88,58 @@ export type {
60
88
 
61
89
  // ── Types ────────────────────────────────────────────────────────────────────
62
90
  export type {
91
+ // Wellness API types (aliases for generated spec types)
63
92
  GarminActivity,
64
- GarminActivityLap,
65
- GarminActivitySample,
66
- GarminDailySummary,
93
+ GarminActivityDetail,
94
+ GarminDaily,
67
95
  GarminSleep,
68
- GarminSleepLevel,
69
96
  GarminBodyComposition,
97
+ GarminMenstrualCycle,
98
+ GarminUserMetrics,
99
+ GarminStressDetail,
100
+ GarminSkinTemperature,
101
+ GarminRespiration,
102
+ GarminPulseOx,
103
+ GarminHRVSummary,
104
+ GarminHealthSnapshot,
105
+ GarminEpoch,
106
+ GarminBloodPressure,
107
+ GarminMoveIQEvent,
108
+ GarminSolar,
109
+ GarminSample,
110
+ GarminLap,
111
+ GarminTimeRange,
112
+ GarminSleepScoreItem,
113
+ GarminNap,
114
+ // Extended types (spec + undocumented fields)
115
+ GarminDailyExtended,
116
+ GarminSleepExtended,
117
+ GarminActivityExtended,
118
+ // Backward compatibility aliases
119
+ GarminDailySummary,
70
120
  GarminMenstrualCycleData,
71
- GarminUserProfile,
72
- GarminActivityType,
121
+ GarminSleepLevel,
122
+ GarminActivityLap,
123
+ GarminActivitySample,
124
+ // Training API types
125
+ GarminWorkout,
126
+ GarminWorkoutStep,
127
+ GarminWorkoutRepeatStep,
128
+ GarminWorkoutSegment,
129
+ GarminWorkoutSchedule,
130
+ GarminWorkoutSport,
131
+ // OAuth
73
132
  GarminOAuth2TokenResponse,
74
- GarminWebhookPayload,
133
+ // Webhook payloads
134
+ GarminWebhookActivityPayload,
135
+ GarminWebhookDailyPayload,
136
+ GarminWebhookSleepPayload,
137
+ GarminWebhookBodyPayload,
138
+ GarminWebhookBloodPressurePayload,
139
+ GarminWebhookSkinTempPayload,
140
+ GarminWebhookUserMetricsPayload,
141
+ GarminWebhookHRVPayload,
142
+ GarminWebhookStressDetailPayload,
143
+ GarminWebhookPulseOxPayload,
144
+ GarminWebhookRespirationPayload,
75
145
  } from "./types.js";
@@ -5,7 +5,7 @@
5
5
  // Garmin values: Garmin Health API activity type strings
6
6
  // Terra values: https://docs.tryterra.co/reference/health-and-fitness-api/data-models#activitytype
7
7
 
8
- import type { GarminActivityType } from "../types.js";
8
+ // Garmin activityType is a free-form string in the spec
9
9
 
10
10
  const activityTypeMap: Record<string, number> = {
11
11
  // ── Running ─────────────────────────────────────────────────────────────
@@ -111,6 +111,6 @@ const activityTypeMap: Record<string, number> = {
111
111
  * Map a Garmin activity type string to the Terra ActivityType enum.
112
112
  * Returns Terra "Other" (108) for unknown types.
113
113
  */
114
- export function mapActivityType(activityType: GarminActivityType): number {
114
+ export function mapActivityType(activityType: string): number {
115
115
  return activityTypeMap[activityType] ?? 108;
116
116
  }
@@ -1,7 +1,7 @@
1
1
  // ─── Menstruation Transformer ────────────────────────────────────────────────
2
2
  // Transforms Garmin menstrual cycle data into the Soma Menstruation schema shape.
3
3
 
4
- import type { GarminMenstrualCycleData } from "./types.js";
4
+ import type { GarminMenstrualCycle } from "./types.js";
5
5
 
6
6
  export type MenstruationData = ReturnType<typeof transformMenstruation>;
7
7
 
@@ -11,16 +11,15 @@ export type MenstruationData = ReturnType<typeof transformMenstruation>;
11
11
  * @param data - The Garmin menstrual cycle data from the Health API
12
12
  * @returns Soma Menstruation fields (without connectionId/userId)
13
13
  */
14
- export function transformMenstruation(data: GarminMenstrualCycleData) {
15
- const dateStr = data.calendarDate;
16
- const startTime = data.startTimeInSeconds
17
- ? new Date(data.startTimeInSeconds * 1000).toISOString()
18
- : `${dateStr}T00:00:00.000Z`;
19
- const endTime = data.startTimeInSeconds
20
- ? new Date(
21
- (data.startTimeInSeconds + 86400) * 1000,
22
- ).toISOString()
23
- : `${dateStr}T23:59:59.999Z`;
14
+ export function transformMenstruation(data: GarminMenstrualCycle) {
15
+ // Spec uses periodStartDate (date string like "2021-01-04")
16
+ const dateStr = data.periodStartDate;
17
+ const startTime = dateStr
18
+ ? `${dateStr}T00:00:00.000Z`
19
+ : undefined;
20
+ const endTime = dateStr
21
+ ? `${dateStr}T23:59:59.999Z`
22
+ : undefined;
24
23
 
25
24
  return {
26
25
  metadata: {
@@ -30,10 +29,13 @@ export function transformMenstruation(data: GarminMenstrualCycleData) {
30
29
 
31
30
  menstruation_data: {
32
31
  day_in_cycle: data.dayInCycle,
33
- current_phase: data.currentPhase?.toLowerCase(),
32
+ // Spec has currentPhaseType (string) replacing deprecated currentPhase (int)
33
+ current_phase: data.currentPhaseType?.toLowerCase(),
34
34
  length_of_current_phase_days: data.lengthOfCurrentPhase,
35
+ days_until_next_phase: data.daysUntilNextPhase,
35
36
  period_length_days: data.periodLength,
36
37
  predicted_cycle_length_days: data.predictedCycleLength,
38
+ cycle_length_days: data.cycleLength,
37
39
  is_predicted_cycle: data.isPredictedCycle != null
38
40
  ? String(data.isPredictedCycle)
39
41
  : undefined,
@@ -0,0 +1,45 @@
1
+ // ─── Pulse Ox Transformer ────────────────────────────────────────────────────
2
+ // Transforms Garmin pulse ox data into fields that enrich a Soma Daily record.
3
+
4
+ import type { GarminPulseOx } from "./types.js";
5
+
6
+ export type PulseOxData = ReturnType<typeof transformPulseOx>;
7
+
8
+ /**
9
+ * Transform a Garmin pulse ox record into oxygen data fields for a Soma Daily.
10
+ *
11
+ * This produces a partial daily shape that can be merged into a matching
12
+ * daily record by date.
13
+ *
14
+ * @param pulseOx - The Garmin pulse ox data from the Health API
15
+ * @returns Partial Soma Daily oxygen_data fields
16
+ */
17
+ export function transformPulseOx(pulseOx: GarminPulseOx) {
18
+ const hasSamples =
19
+ pulseOx.timeOffsetSpo2Values != null &&
20
+ Object.keys(pulseOx.timeOffsetSpo2Values).length > 0;
21
+
22
+ if (!hasSamples) {
23
+ return {
24
+ calendar_date: pulseOx.calendarDate,
25
+ oxygen_data: undefined,
26
+ };
27
+ }
28
+
29
+ const startTime = pulseOx.startTimeInSeconds ?? 0;
30
+ const samples = Object.entries(pulseOx.timeOffsetSpo2Values!).map(
31
+ ([offset, percentage]) => ({
32
+ timestamp: new Date(
33
+ (startTime + parseInt(offset, 10)) * 1000,
34
+ ).toISOString(),
35
+ percentage,
36
+ }),
37
+ );
38
+
39
+ return {
40
+ calendar_date: pulseOx.calendarDate,
41
+ oxygen_data: {
42
+ saturation_samples: samples.length > 0 ? samples : undefined,
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,55 @@
1
+ // ─── Respiration Transformer ─────────────────────────────────────────────────
2
+ // Transforms Garmin respiration data into fields that enrich a Soma Daily record.
3
+
4
+ import type { GarminRespiration } from "./types.js";
5
+
6
+ export type RespirationData = ReturnType<typeof transformRespiration>;
7
+
8
+ /**
9
+ * Transform a Garmin respiration record into respiration data fields for a Soma Daily.
10
+ *
11
+ * This produces a partial daily shape that can be merged into a matching
12
+ * daily record by date.
13
+ *
14
+ * @param resp - The Garmin respiration data from the Health API
15
+ * @returns Partial Soma Daily respiration_data fields
16
+ */
17
+ export function transformRespiration(resp: GarminRespiration) {
18
+ const hasSamples =
19
+ resp.timeOffsetEpochToBreaths != null &&
20
+ Object.keys(resp.timeOffsetEpochToBreaths).length > 0;
21
+
22
+ if (!hasSamples) {
23
+ return {
24
+ calendar_date: undefined,
25
+ respiration_data: undefined,
26
+ };
27
+ }
28
+
29
+ const startTime = resp.startTimeInSeconds ?? 0;
30
+ const samples = Object.entries(resp.timeOffsetEpochToBreaths!).map(
31
+ ([offset, breaths_per_min]) => ({
32
+ timestamp: new Date(
33
+ (startTime + parseInt(offset, 10)) * 1000,
34
+ ).toISOString(),
35
+ breaths_per_min,
36
+ }),
37
+ );
38
+
39
+ // Compute summary stats from samples
40
+ const values = samples.map((s) => s.breaths_per_min);
41
+ const avg = values.reduce((sum, v) => sum + v, 0) / values.length;
42
+
43
+ return {
44
+ // Respiration endpoint doesn't have calendarDate, derive from startTime
45
+ calendar_date: new Date(startTime * 1000).toISOString().split("T")[0],
46
+ respiration_data: {
47
+ breaths_data: {
48
+ avg_breaths_per_min: Math.round(avg * 10) / 10,
49
+ min_breaths_per_min: Math.min(...values),
50
+ max_breaths_per_min: Math.max(...values),
51
+ samples: samples.length > 0 ? samples : undefined,
52
+ },
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,42 @@
1
+ // ─── Skin Temperature Transformer ────────────────────────────────────────────
2
+ // Transforms Garmin skin temperature data into the Soma Body schema shape.
3
+
4
+ import type { GarminSkinTemperature } from "./types.js";
5
+
6
+ export type SkinTempData = ReturnType<typeof transformSkinTemp>;
7
+
8
+ /**
9
+ * Transform a Garmin skin temperature record into a Soma Body document shape.
10
+ *
11
+ * @param skinTemp - The Garmin skin temperature data from the Health API
12
+ * @returns Soma Body fields (without connectionId/userId)
13
+ */
14
+ export function transformSkinTemp(skinTemp: GarminSkinTemperature) {
15
+ const startMs = (skinTemp.startTimeInSeconds ?? 0) * 1000;
16
+ const endMs = startMs + (skinTemp.durationInSeconds ?? 0) * 1000;
17
+ const startTime = new Date(startMs).toISOString();
18
+ const endTime = new Date(endMs).toISOString();
19
+
20
+ if (skinTemp.avgDeviationCelsius == null) {
21
+ return {
22
+ metadata: { start_time: startTime, end_time: endTime },
23
+ temperature_data: undefined,
24
+ };
25
+ }
26
+
27
+ return {
28
+ metadata: {
29
+ start_time: startTime,
30
+ end_time: endTime,
31
+ },
32
+
33
+ temperature_data: {
34
+ skin_temperature_samples: [
35
+ {
36
+ timestamp: startTime,
37
+ temperature_celsius: skinTemp.avgDeviationCelsius,
38
+ },
39
+ ],
40
+ },
41
+ };
42
+ }
@@ -1,10 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { transformSleep } from "./sleep.js";
3
- import type { GarminSleep } from "./types.js";
3
+ import type { GarminSleepExtended } from "./types.js";
4
4
 
5
- const baseSleep: GarminSleep = {
5
+ const baseSleep: GarminSleepExtended = {
6
6
  userId: "garmin_user_1",
7
- userAccessToken: "token",
8
7
  summaryId: "sleep_001",
9
8
  calendarDate: "2023-11-14",
10
9
  startTimeInSeconds: 1700000000,
@@ -36,7 +35,7 @@ describe("transformSleep", () => {
36
35
  });
37
36
 
38
37
  it("maps sleep stage durations", () => {
39
- const withStages: GarminSleep = {
38
+ const withStages: GarminSleepExtended = {
40
39
  ...baseSleep,
41
40
  deepSleepDurationInSeconds: 7200,
42
41
  lightSleepDurationInSeconds: 14400,
@@ -55,7 +54,7 @@ describe("transformSleep", () => {
55
54
  });
56
55
 
57
56
  it("maps sleep levels hypnogram", () => {
58
- const withLevels: GarminSleep = {
57
+ const withLevels: GarminSleepExtended = {
59
58
  ...baseSleep,
60
59
  sleepLevelsMap: {
61
60
  deep: [
@@ -85,7 +84,7 @@ describe("transformSleep", () => {
85
84
  });
86
85
 
87
86
  it("maps respiration data", () => {
88
- const withResp: GarminSleep = {
87
+ const withResp: GarminSleepExtended = {
89
88
  ...baseSleep,
90
89
  averageRespirationInBreathsPerMinute: 16,
91
90
  lowestRespirationInBreathsPerMinute: 12,
@@ -1,7 +1,7 @@
1
1
  // ─── Sleep Transformer ───────────────────────────────────────────────────────
2
2
  // Transforms a Garmin sleep session into the Soma Sleep schema shape.
3
3
 
4
- import type { GarminSleep, GarminSleepLevel } from "./types.js";
4
+ import type { GarminSleepExtended, GarminTimeRange } from "./types.js";
5
5
  import { mapSleepLevel } from "./maps/sleep-level.js";
6
6
 
7
7
  export type SleepData = ReturnType<typeof transformSleep>;
@@ -12,9 +12,9 @@ export type SleepData = ReturnType<typeof transformSleep>;
12
12
  * @param sleep - The Garmin sleep data from the Health API
13
13
  * @returns Soma Sleep fields (without connectionId/userId)
14
14
  */
15
- export function transformSleep(sleep: GarminSleep) {
16
- const startMs = sleep.startTimeInSeconds * 1000;
17
- const endMs = startMs + sleep.durationInSeconds * 1000;
15
+ export function transformSleep(sleep: GarminSleepExtended) {
16
+ const startMs = (sleep.startTimeInSeconds ?? 0) * 1000;
17
+ const endMs = startMs + (sleep.durationInSeconds ?? 0) * 1000;
18
18
 
19
19
  const uploadTypeMap: Record<string, number> = {
20
20
  ENHANCED_FINAL: 2, // Automatic
@@ -22,6 +22,7 @@ export function transformSleep(sleep: GarminSleep) {
22
22
  AUTO_FINAL: 2, // Automatic
23
23
  AUTO_TENTATIVE: 4, // Indeterminate
24
24
  MANUAL: 1, // Manual
25
+ DEVICE: 2, // From device
25
26
  };
26
27
 
27
28
  return {
@@ -29,7 +30,7 @@ export function transformSleep(sleep: GarminSleep) {
29
30
  summary_id: sleep.summaryId,
30
31
  start_time: new Date(startMs).toISOString(),
31
32
  end_time: new Date(endMs).toISOString(),
32
- upload_type: uploadTypeMap[sleep.validation] ?? 0,
33
+ upload_type: uploadTypeMap[sleep.validation ?? ""] ?? 0,
33
34
  },
34
35
 
35
36
  sleep_durations_data: buildSleepDurationsData(sleep),
@@ -42,7 +43,7 @@ export function transformSleep(sleep: GarminSleep) {
42
43
 
43
44
  // ─── Helpers ──────────────────────────────────────────────────────────────────
44
45
 
45
- function buildSleepDurationsData(sleep: GarminSleep) {
46
+ function buildSleepDurationsData(sleep: GarminSleepExtended) {
46
47
  const totalAsleep =
47
48
  (sleep.deepSleepDurationInSeconds ?? 0) +
48
49
  (sleep.lightSleepDurationInSeconds ?? 0) +
@@ -66,7 +67,7 @@ function buildSleepDurationsData(sleep: GarminSleep) {
66
67
  };
67
68
  }
68
69
 
69
- function buildHypnogramSamples(sleep: GarminSleep) {
70
+ function buildHypnogramSamples(sleep: GarminSleepExtended) {
70
71
  if (!sleep.sleepLevelsMap) return undefined;
71
72
 
72
73
  const samples: Array<{ timestamp: string; level: number }> = [];
@@ -75,7 +76,8 @@ function buildHypnogramSamples(sleep: GarminSleep) {
75
76
  if (!levels) continue;
76
77
  const terraLevel = mapSleepLevel(stage);
77
78
 
78
- for (const level of levels as GarminSleepLevel[]) {
79
+ for (const level of levels as GarminTimeRange[]) {
80
+ if (level.startTimeInSeconds == null) continue;
79
81
  samples.push({
80
82
  timestamp: new Date(level.startTimeInSeconds * 1000).toISOString(),
81
83
  level: terraLevel,
@@ -90,7 +92,7 @@ function buildHypnogramSamples(sleep: GarminSleep) {
90
92
  return samples.length > 0 ? samples : undefined;
91
93
  }
92
94
 
93
- function buildHeartRateData(sleep: GarminSleep) {
95
+ function buildHeartRateData(sleep: GarminSleepExtended) {
94
96
  if (
95
97
  !sleep.timeOffsetHeartRateSamples ||
96
98
  Object.keys(sleep.timeOffsetHeartRateSamples).length === 0
@@ -98,10 +100,11 @@ function buildHeartRateData(sleep: GarminSleep) {
98
100
  return undefined;
99
101
  }
100
102
 
103
+ const startTime = sleep.startTimeInSeconds ?? 0;
101
104
  const hrSamples = Object.entries(sleep.timeOffsetHeartRateSamples).map(
102
105
  ([offset, bpm]) => ({
103
106
  timestamp: new Date(
104
- (sleep.startTimeInSeconds + parseInt(offset, 10)) * 1000,
107
+ (startTime + parseInt(offset, 10)) * 1000,
105
108
  ).toISOString(),
106
109
  bpm,
107
110
  }),
@@ -112,25 +115,28 @@ function buildHeartRateData(sleep: GarminSleep) {
112
115
  };
113
116
  }
114
117
 
115
- function buildRespirationData(sleep: GarminSleep) {
118
+ function buildRespirationData(sleep: GarminSleepExtended) {
116
119
  const hasBreathSummary =
117
120
  sleep.averageRespirationInBreathsPerMinute != null;
118
121
  const hasBreathSamples =
119
122
  sleep.timeOffsetSleepRespiration != null &&
120
123
  Object.keys(sleep.timeOffsetSleepRespiration).length > 0;
124
+ // Check both spec field (timeOffsetSleepSpo2) and extended field
125
+ const spo2Map = sleep.timeOffsetSleepSpo2 ?? sleep.timeOffsetSpo2Values;
121
126
  const hasSpO2Samples =
122
- sleep.timeOffsetSpo2Values != null &&
123
- Object.keys(sleep.timeOffsetSpo2Values).length > 0;
127
+ spo2Map != null && Object.keys(spo2Map).length > 0;
124
128
 
125
129
  if (!hasBreathSummary && !hasBreathSamples && !hasSpO2Samples) {
126
130
  return undefined;
127
131
  }
128
132
 
133
+ const startTime = sleep.startTimeInSeconds ?? 0;
134
+
129
135
  const breathSamples = hasBreathSamples
130
136
  ? Object.entries(sleep.timeOffsetSleepRespiration!).map(
131
137
  ([offset, rate]) => ({
132
138
  timestamp: new Date(
133
- (sleep.startTimeInSeconds + parseInt(offset, 10)) * 1000,
139
+ (startTime + parseInt(offset, 10)) * 1000,
134
140
  ).toISOString(),
135
141
  breaths_per_min: rate,
136
142
  }),
@@ -138,9 +144,9 @@ function buildRespirationData(sleep: GarminSleep) {
138
144
  : undefined;
139
145
 
140
146
  const spo2Samples = hasSpO2Samples
141
- ? Object.entries(sleep.timeOffsetSpo2Values!).map(([offset, pct]) => ({
147
+ ? Object.entries(spo2Map!).map(([offset, pct]) => ({
142
148
  timestamp: new Date(
143
- (sleep.startTimeInSeconds + parseInt(offset, 10)) * 1000,
149
+ (startTime + parseInt(offset, 10)) * 1000,
144
150
  ).toISOString(),
145
151
  percentage: pct,
146
152
  }))