@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.
- package/dist/client/index.d.ts +83 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +131 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +159 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +190 -6
- package/dist/component/garmin.d.ts.map +1 -1
- package/dist/component/garmin.js +805 -25
- package/dist/component/garmin.js.map +1 -1
- package/dist/component/private.d.ts +18 -0
- package/dist/component/private.d.ts.map +1 -1
- package/dist/component/private.js +18 -0
- package/dist/component/private.js.map +1 -1
- package/dist/component/public.d.ts +88 -42
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +12 -2
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +87 -32
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +2 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/validators/connection.d.ts +1 -0
- package/dist/component/validators/connection.d.ts.map +1 -1
- package/dist/component/validators/connection.js +2 -0
- package/dist/component/validators/connection.js.map +1 -1
- package/dist/component/validators/daily.d.ts +40 -5
- package/dist/component/validators/daily.d.ts.map +1 -1
- package/dist/component/validators/daily.js +10 -1
- package/dist/component/validators/daily.js.map +1 -1
- package/dist/component/validators/enums.d.ts +1 -1
- package/dist/component/validators/plannedWorkout.d.ts +5 -1
- package/dist/component/validators/plannedWorkout.d.ts.map +1 -1
- package/dist/component/validators/plannedWorkout.js +4 -0
- package/dist/component/validators/plannedWorkout.js.map +1 -1
- package/dist/component/validators/sleep.d.ts +8 -8
- package/dist/garmin/activity.d.ts +7 -16
- package/dist/garmin/activity.d.ts.map +1 -1
- package/dist/garmin/activity.js +17 -23
- package/dist/garmin/activity.js.map +1 -1
- package/dist/garmin/bloodPressure.d.ts +28 -0
- package/dist/garmin/bloodPressure.d.ts.map +1 -0
- package/dist/garmin/bloodPressure.js +34 -0
- package/dist/garmin/bloodPressure.js.map +1 -0
- package/dist/garmin/body.js +1 -1
- package/dist/garmin/body.js.map +1 -1
- package/dist/garmin/client.d.ts +117 -17
- package/dist/garmin/client.d.ts.map +1 -1
- package/dist/garmin/client.js +337 -43
- package/dist/garmin/client.js.map +1 -1
- package/dist/garmin/daily.d.ts.map +1 -1
- package/dist/garmin/daily.js +3 -3
- package/dist/garmin/daily.js.map +1 -1
- package/dist/garmin/hrv.d.ts +30 -0
- package/dist/garmin/hrv.d.ts.map +1 -0
- package/dist/garmin/hrv.js +45 -0
- package/dist/garmin/hrv.js.map +1 -0
- package/dist/garmin/index.d.ts +16 -2
- package/dist/garmin/index.d.ts.map +1 -1
- package/dist/garmin/index.js +8 -1
- package/dist/garmin/index.js.map +1 -1
- package/dist/garmin/maps/activity-type.d.ts +1 -2
- package/dist/garmin/maps/activity-type.d.ts.map +1 -1
- package/dist/garmin/maps/activity-type.js +1 -0
- package/dist/garmin/maps/activity-type.js.map +1 -1
- package/dist/garmin/menstruation.d.ts +6 -4
- package/dist/garmin/menstruation.d.ts.map +1 -1
- package/dist/garmin/menstruation.js +12 -8
- package/dist/garmin/menstruation.js.map +1 -1
- package/dist/garmin/pulseOx.d.ts +24 -0
- package/dist/garmin/pulseOx.d.ts.map +1 -0
- package/dist/garmin/pulseOx.js +33 -0
- package/dist/garmin/pulseOx.js.map +1 -0
- package/dist/garmin/respiration.d.ts +29 -0
- package/dist/garmin/respiration.d.ts.map +1 -0
- package/dist/garmin/respiration.js +42 -0
- package/dist/garmin/respiration.js.map +1 -0
- package/dist/garmin/skinTemp.d.ts +27 -0
- package/dist/garmin/skinTemp.d.ts.map +1 -0
- package/dist/garmin/skinTemp.js +35 -0
- package/dist/garmin/skinTemp.js.map +1 -0
- package/dist/garmin/sleep.d.ts +4 -4
- package/dist/garmin/sleep.d.ts.map +1 -1
- package/dist/garmin/sleep.js +15 -9
- package/dist/garmin/sleep.js.map +1 -1
- package/dist/garmin/stressDetails.d.ts +30 -0
- package/dist/garmin/stressDetails.d.ts.map +1 -0
- package/dist/garmin/stressDetails.js +49 -0
- package/dist/garmin/stressDetails.js.map +1 -0
- package/dist/garmin/sync.d.ts +14 -0
- package/dist/garmin/sync.d.ts.map +1 -1
- package/dist/garmin/sync.js +287 -5
- package/dist/garmin/sync.js.map +1 -1
- package/dist/garmin/types.d.ts +77 -186
- package/dist/garmin/types.d.ts.map +1 -1
- package/dist/garmin/types.js +4 -2
- package/dist/garmin/types.js.map +1 -1
- package/dist/garmin/userMetrics.d.ts +23 -0
- package/dist/garmin/userMetrics.d.ts.map +1 -0
- package/dist/garmin/userMetrics.js +41 -0
- package/dist/garmin/userMetrics.js.map +1 -0
- package/dist/validators.d.ts +107 -28
- package/dist/validators.d.ts.map +1 -1
- package/package.json +133 -124
- package/src/client/index.ts +199 -0
- package/src/component/_generated/component.ts +161 -2
- package/src/component/garmin.ts +898 -26
- package/src/component/private.ts +21 -0
- package/src/component/public.ts +11 -2
- package/src/component/schema.ts +2 -1
- package/src/component/validators/connection.ts +2 -0
- package/src/component/validators/daily.ts +15 -0
- package/src/component/validators/plannedWorkout.ts +4 -0
- package/src/garmin/activity.test.ts +13 -21
- package/src/garmin/activity.ts +38 -45
- package/src/garmin/bloodPressure.ts +41 -0
- package/src/garmin/body.ts +1 -1
- package/src/garmin/client.ts +550 -71
- package/src/garmin/daily.ts +8 -4
- package/src/garmin/hrv.ts +57 -0
- package/src/garmin/index.ts +77 -7
- package/src/garmin/maps/activity-type.ts +2 -2
- package/src/garmin/menstruation.ts +14 -12
- package/src/garmin/pulseOx.ts +45 -0
- package/src/garmin/respiration.ts +55 -0
- package/src/garmin/skinTemp.ts +42 -0
- package/src/garmin/sleep.test.ts +5 -6
- package/src/garmin/sleep.ts +22 -16
- package/src/garmin/spec/wellness-api.json +1 -0
- package/src/garmin/stressDetails.ts +71 -0
- package/src/garmin/sync.ts +348 -5
- package/src/garmin/types.ts +88 -300
- package/src/garmin/userMetrics.ts +50 -0
- package/src/garmin/wellness-api.d.ts +5637 -0
package/src/garmin/daily.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/garmin/index.ts
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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:
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/garmin/sleep.test.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { transformSleep } from "./sleep.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { GarminSleepExtended } from "./types.js";
|
|
4
4
|
|
|
5
|
-
const baseSleep:
|
|
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:
|
|
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:
|
|
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:
|
|
87
|
+
const withResp: GarminSleepExtended = {
|
|
89
88
|
...baseSleep,
|
|
90
89
|
averageRespirationInBreathsPerMinute: 16,
|
|
91
90
|
lowestRespirationInBreathsPerMinute: 12,
|
package/src/garmin/sleep.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
(
|
|
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:
|
|
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
|
-
|
|
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
|
-
(
|
|
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(
|
|
147
|
+
? Object.entries(spo2Map!).map(([offset, pct]) => ({
|
|
142
148
|
timestamp: new Date(
|
|
143
|
-
(
|
|
149
|
+
(startTime + parseInt(offset, 10)) * 1000,
|
|
144
150
|
).toISOString(),
|
|
145
151
|
percentage: pct,
|
|
146
152
|
}))
|