@nativesquare/soma 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.d.ts +283 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +328 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +77 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +164 -0
- package/dist/component/garmin.d.ts.map +1 -0
- package/dist/component/garmin.js +609 -0
- package/dist/component/garmin.js.map +1 -0
- package/dist/component/public.d.ts +761 -761
- package/dist/component/schema.d.ts +405 -388
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +14 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/strava.d.ts +5 -4
- package/dist/component/strava.d.ts.map +1 -1
- package/dist/component/strava.js +18 -1
- package/dist/component/strava.js.map +1 -1
- package/dist/component/validators/activity.d.ts +42 -42
- package/dist/component/validators/body.d.ts +47 -47
- package/dist/component/validators/daily.d.ts +17 -17
- package/dist/component/validators/plannedWorkout.d.ts +5 -5
- package/dist/component/validators/samples.d.ts +2 -2
- package/dist/component/validators/shared.d.ts +17 -17
- package/dist/component/validators/sleep.d.ts +17 -17
- package/dist/garmin/activity.d.ts +101 -0
- package/dist/garmin/activity.d.ts.map +1 -0
- package/dist/garmin/activity.js +207 -0
- package/dist/garmin/activity.js.map +1 -0
- package/dist/garmin/auth.d.ts +65 -0
- package/dist/garmin/auth.d.ts.map +1 -0
- package/dist/garmin/auth.js +155 -0
- package/dist/garmin/auth.js.map +1 -0
- package/dist/garmin/body.d.ts +26 -0
- package/dist/garmin/body.d.ts.map +1 -0
- package/dist/garmin/body.js +44 -0
- package/dist/garmin/body.js.map +1 -0
- package/dist/garmin/client.d.ts +99 -0
- package/dist/garmin/client.d.ts.map +1 -0
- package/dist/garmin/client.js +153 -0
- package/dist/garmin/client.js.map +1 -0
- package/dist/garmin/daily.d.ts +74 -0
- package/dist/garmin/daily.d.ts.map +1 -0
- package/dist/garmin/daily.js +143 -0
- package/dist/garmin/daily.js.map +1 -0
- package/dist/garmin/index.d.ts +20 -0
- package/dist/garmin/index.d.ts.map +1 -0
- package/dist/garmin/index.js +21 -0
- package/dist/garmin/index.js.map +1 -0
- package/dist/garmin/maps/activity-type.d.ts +7 -0
- package/dist/garmin/maps/activity-type.d.ts.map +1 -0
- package/dist/garmin/maps/activity-type.js +98 -0
- package/dist/garmin/maps/activity-type.js.map +1 -0
- package/dist/garmin/maps/sleep-level.d.ts +6 -0
- package/dist/garmin/maps/sleep-level.d.ts.map +1 -0
- package/dist/garmin/maps/sleep-level.js +21 -0
- package/dist/garmin/maps/sleep-level.js.map +1 -0
- package/dist/garmin/menstruation.d.ts +23 -0
- package/dist/garmin/menstruation.d.ts.map +1 -0
- package/dist/garmin/menstruation.js +34 -0
- package/dist/garmin/menstruation.js.map +1 -0
- package/dist/garmin/sleep.d.ts +62 -0
- package/dist/garmin/sleep.d.ts.map +1 -0
- package/dist/garmin/sleep.js +125 -0
- package/dist/garmin/sleep.js.map +1 -0
- package/dist/garmin/sync.d.ts +39 -0
- package/dist/garmin/sync.d.ts.map +1 -0
- package/dist/garmin/sync.js +175 -0
- package/dist/garmin/sync.js.map +1 -0
- package/dist/garmin/types.d.ts +212 -0
- package/dist/garmin/types.d.ts.map +1 -0
- package/dist/garmin/types.js +8 -0
- package/dist/garmin/types.js.map +1 -0
- package/dist/validators.d.ts +331 -331
- package/package.json +5 -1
- package/src/client/index.ts +446 -1
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +89 -0
- package/src/component/garmin.ts +711 -0
- package/src/component/schema.ts +15 -2
- package/src/component/strava.ts +23 -1
- package/src/garmin/activity.test.ts +178 -0
- package/src/garmin/activity.ts +272 -0
- package/src/garmin/auth.test.ts +128 -0
- package/src/garmin/auth.ts +249 -0
- package/src/garmin/body.ts +59 -0
- package/src/garmin/client.ts +254 -0
- package/src/garmin/daily.ts +211 -0
- package/src/garmin/index.ts +76 -0
- package/src/garmin/maps/activity-type.test.ts +78 -0
- package/src/garmin/maps/activity-type.ts +116 -0
- package/src/garmin/maps/sleep-level.ts +22 -0
- package/src/garmin/menstruation.ts +42 -0
- package/src/garmin/sleep.test.ts +110 -0
- package/src/garmin/sleep.ts +170 -0
- package/src/garmin/sync.ts +223 -0
- package/src/garmin/types.ts +338 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// ─── Daily Transformer ───────────────────────────────────────────────────────
|
|
2
|
+
// Transforms a Garmin daily wellness summary into the Soma Daily schema shape.
|
|
3
|
+
|
|
4
|
+
import type { GarminDailySummary } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type DailyData = ReturnType<typeof transformDaily>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Transform a Garmin daily summary into a Soma Daily document shape.
|
|
10
|
+
*
|
|
11
|
+
* @param daily - The Garmin daily summary from the Health API
|
|
12
|
+
* @returns Soma Daily fields (without connectionId/userId)
|
|
13
|
+
*/
|
|
14
|
+
export function transformDaily(daily: GarminDailySummary) {
|
|
15
|
+
const startMs = daily.startTimeInSeconds * 1000;
|
|
16
|
+
const endMs = startMs + daily.durationInSeconds * 1000;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
metadata: {
|
|
20
|
+
start_time: new Date(startMs).toISOString(),
|
|
21
|
+
end_time: new Date(endMs).toISOString(),
|
|
22
|
+
upload_type: 1, // Automatic
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
active_durations_data: buildActiveDurationsData(daily),
|
|
26
|
+
|
|
27
|
+
calories_data: buildCaloriesData(daily),
|
|
28
|
+
|
|
29
|
+
distance_data: buildDistanceData(daily),
|
|
30
|
+
|
|
31
|
+
heart_rate_data: buildHeartRateData(daily),
|
|
32
|
+
|
|
33
|
+
oxygen_data: buildOxygenData(daily),
|
|
34
|
+
|
|
35
|
+
stress_data: buildStressData(daily),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function buildActiveDurationsData(daily: GarminDailySummary) {
|
|
42
|
+
if (
|
|
43
|
+
daily.activeTimeInSeconds == null &&
|
|
44
|
+
daily.moderateIntensityDurationInSeconds == null &&
|
|
45
|
+
daily.vigorousIntensityDurationInSeconds == null
|
|
46
|
+
) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
activity_seconds: daily.activeTimeInSeconds,
|
|
52
|
+
moderate_intensity_seconds: daily.moderateIntensityDurationInSeconds,
|
|
53
|
+
vigorous_intensity_seconds: daily.vigorousIntensityDurationInSeconds,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildCaloriesData(daily: GarminDailySummary) {
|
|
58
|
+
if (daily.activeKilocalories == null && daily.bmrKilocalories == null) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const total =
|
|
63
|
+
(daily.activeKilocalories ?? 0) + (daily.bmrKilocalories ?? 0);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
net_activity_calories: daily.activeKilocalories,
|
|
67
|
+
BMR_calories: daily.bmrKilocalories,
|
|
68
|
+
total_burned_calories: total || undefined,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildDistanceData(daily: GarminDailySummary) {
|
|
73
|
+
if (
|
|
74
|
+
daily.distanceInMeters == null &&
|
|
75
|
+
daily.steps == null &&
|
|
76
|
+
daily.floorsClimbed == null
|
|
77
|
+
) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
distance_meters: daily.distanceInMeters,
|
|
83
|
+
steps: daily.steps,
|
|
84
|
+
floors_climbed: daily.floorsClimbed,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildHeartRateData(daily: GarminDailySummary) {
|
|
89
|
+
const hasSummary =
|
|
90
|
+
daily.averageHeartRateInBeatsPerMinute != null ||
|
|
91
|
+
daily.maxHeartRateInBeatsPerMinute != null ||
|
|
92
|
+
daily.restingHeartRateInBeatsPerMinute != null;
|
|
93
|
+
const hasSamples =
|
|
94
|
+
daily.timeOffsetHeartRateSamples != null &&
|
|
95
|
+
Object.keys(daily.timeOffsetHeartRateSamples).length > 0;
|
|
96
|
+
|
|
97
|
+
if (!hasSummary && !hasSamples) return undefined;
|
|
98
|
+
|
|
99
|
+
const hrSamples = hasSamples
|
|
100
|
+
? buildTimeOffsetSamples(
|
|
101
|
+
daily.startTimeInSeconds,
|
|
102
|
+
daily.timeOffsetHeartRateSamples!,
|
|
103
|
+
(bpm) => ({ bpm }),
|
|
104
|
+
)
|
|
105
|
+
: undefined;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
summary: hasSummary
|
|
109
|
+
? {
|
|
110
|
+
avg_hr_bpm: daily.averageHeartRateInBeatsPerMinute,
|
|
111
|
+
max_hr_bpm: daily.maxHeartRateInBeatsPerMinute,
|
|
112
|
+
min_hr_bpm: daily.minHeartRateInBeatsPerMinute,
|
|
113
|
+
resting_hr_bpm: daily.restingHeartRateInBeatsPerMinute,
|
|
114
|
+
}
|
|
115
|
+
: undefined,
|
|
116
|
+
detailed:
|
|
117
|
+
hrSamples && hrSamples.length > 0
|
|
118
|
+
? { hr_samples: hrSamples }
|
|
119
|
+
: undefined,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildOxygenData(daily: GarminDailySummary) {
|
|
124
|
+
if (daily.averageSpo2Value == null && daily.timeOffsetSpo2Values == null) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const samples =
|
|
129
|
+
daily.timeOffsetSpo2Values != null
|
|
130
|
+
? buildTimeOffsetSamples(
|
|
131
|
+
daily.startTimeInSeconds,
|
|
132
|
+
daily.timeOffsetSpo2Values,
|
|
133
|
+
(pct) => ({ percentage: pct }),
|
|
134
|
+
)
|
|
135
|
+
: undefined;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
avg_saturation_percentage: daily.averageSpo2Value,
|
|
139
|
+
saturation_samples:
|
|
140
|
+
samples && samples.length > 0 ? samples : undefined,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildStressData(daily: GarminDailySummary) {
|
|
145
|
+
const hasStress =
|
|
146
|
+
daily.averageStressLevel != null ||
|
|
147
|
+
daily.maxStressLevel != null ||
|
|
148
|
+
daily.stressDurationInSeconds != null;
|
|
149
|
+
const hasStressSamples =
|
|
150
|
+
daily.timeOffsetStressLevelValues != null &&
|
|
151
|
+
Object.keys(daily.timeOffsetStressLevelValues).length > 0;
|
|
152
|
+
const hasBodyBatterySamples =
|
|
153
|
+
daily.timeOffsetBodyBatteryValues != null &&
|
|
154
|
+
Object.keys(daily.timeOffsetBodyBatteryValues).length > 0;
|
|
155
|
+
|
|
156
|
+
if (!hasStress && !hasStressSamples && !hasBodyBatterySamples) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const stressSamples = hasStressSamples
|
|
161
|
+
? buildTimeOffsetSamples(
|
|
162
|
+
daily.startTimeInSeconds,
|
|
163
|
+
daily.timeOffsetStressLevelValues!,
|
|
164
|
+
(level) => ({ level }),
|
|
165
|
+
)
|
|
166
|
+
: undefined;
|
|
167
|
+
|
|
168
|
+
const bodyBatterySamples = hasBodyBatterySamples
|
|
169
|
+
? buildTimeOffsetSamples(
|
|
170
|
+
daily.startTimeInSeconds,
|
|
171
|
+
daily.timeOffsetBodyBatteryValues!,
|
|
172
|
+
(level) => ({ level }),
|
|
173
|
+
)
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
avg_stress_level: daily.averageStressLevel,
|
|
178
|
+
max_stress_level: daily.maxStressLevel,
|
|
179
|
+
stress_duration_seconds: daily.stressDurationInSeconds,
|
|
180
|
+
rest_stress_duration_seconds: daily.restStressDurationInSeconds,
|
|
181
|
+
activity_stress_duration_seconds: daily.activityStressDurationInSeconds,
|
|
182
|
+
low_stress_duration_seconds: daily.lowStressDurationInSeconds,
|
|
183
|
+
medium_stress_duration_seconds: daily.mediumStressDurationInSeconds,
|
|
184
|
+
high_stress_duration_seconds: daily.highStressDurationInSeconds,
|
|
185
|
+
samples:
|
|
186
|
+
stressSamples && stressSamples.length > 0 ? stressSamples : undefined,
|
|
187
|
+
body_battery_samples:
|
|
188
|
+
bodyBatterySamples && bodyBatterySamples.length > 0
|
|
189
|
+
? bodyBatterySamples
|
|
190
|
+
: undefined,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Convert Garmin offset-keyed time-series data into timestamped samples.
|
|
196
|
+
*
|
|
197
|
+
* Garmin sends time-series as `{ [offsetSeconds]: value }` maps.
|
|
198
|
+
* This converts to `{ timestamp: ISO-8601, ...fields }[]`.
|
|
199
|
+
*/
|
|
200
|
+
function buildTimeOffsetSamples<T extends Record<string, unknown>>(
|
|
201
|
+
startTimeInSeconds: number,
|
|
202
|
+
offsets: Record<string, number>,
|
|
203
|
+
mapValue: (value: number) => T,
|
|
204
|
+
): Array<{ timestamp: string } & T> {
|
|
205
|
+
return Object.entries(offsets).map(([offset, value]) => ({
|
|
206
|
+
timestamp: new Date(
|
|
207
|
+
(startTimeInSeconds + parseInt(offset, 10)) * 1000,
|
|
208
|
+
).toISOString(),
|
|
209
|
+
...mapValue(value),
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// ─── @nativesquare/soma/garmin ───────────────────────────────────────────────
|
|
2
|
+
// Garmin Health API → Soma schema transformers, API client, OAuth helpers, and sync.
|
|
3
|
+
//
|
|
4
|
+
// Uses the Web Crypto API for OAuth 1.0a HMAC-SHA1 signing.
|
|
5
|
+
// Compatible with both the Convex V8 runtime and Node.js environments.
|
|
6
|
+
|
|
7
|
+
// ── Transformers ─────────────────────────────────────────────────────────────
|
|
8
|
+
export { transformActivity } from "./activity.js";
|
|
9
|
+
export type { ActivityData } from "./activity.js";
|
|
10
|
+
|
|
11
|
+
export { transformDaily } from "./daily.js";
|
|
12
|
+
export type { DailyData } from "./daily.js";
|
|
13
|
+
|
|
14
|
+
export { transformSleep } from "./sleep.js";
|
|
15
|
+
export type { SleepData } from "./sleep.js";
|
|
16
|
+
|
|
17
|
+
export { transformBody } from "./body.js";
|
|
18
|
+
export type { BodyData } from "./body.js";
|
|
19
|
+
|
|
20
|
+
export { transformMenstruation } from "./menstruation.js";
|
|
21
|
+
export type { MenstruationData } from "./menstruation.js";
|
|
22
|
+
|
|
23
|
+
// ── Enum Maps ────────────────────────────────────────────────────────────────
|
|
24
|
+
export { mapActivityType } from "./maps/activity-type.js";
|
|
25
|
+
export { mapSleepLevel } from "./maps/sleep-level.js";
|
|
26
|
+
|
|
27
|
+
// ── API Client ───────────────────────────────────────────────────────────────
|
|
28
|
+
export { GarminClient, GarminApiError } from "./client.js";
|
|
29
|
+
export type { GarminClientOptions, TimeRangeParams } from "./client.js";
|
|
30
|
+
|
|
31
|
+
// ── OAuth Helpers ────────────────────────────────────────────────────────────
|
|
32
|
+
export {
|
|
33
|
+
getRequestToken,
|
|
34
|
+
getAccessToken,
|
|
35
|
+
buildOAuthSignature,
|
|
36
|
+
buildOAuthHeader,
|
|
37
|
+
percentEncode,
|
|
38
|
+
generateNonce,
|
|
39
|
+
getTimestamp,
|
|
40
|
+
} from "./auth.js";
|
|
41
|
+
export type {
|
|
42
|
+
GetRequestTokenOptions,
|
|
43
|
+
GetAccessTokenOptions,
|
|
44
|
+
} from "./auth.js";
|
|
45
|
+
|
|
46
|
+
// ── Sync Helpers ─────────────────────────────────────────────────────────────
|
|
47
|
+
export {
|
|
48
|
+
syncAll,
|
|
49
|
+
syncActivities,
|
|
50
|
+
syncDailies,
|
|
51
|
+
syncSleep,
|
|
52
|
+
syncBody,
|
|
53
|
+
syncMenstruation,
|
|
54
|
+
} from "./sync.js";
|
|
55
|
+
export type {
|
|
56
|
+
SyncOptions,
|
|
57
|
+
SyncResult,
|
|
58
|
+
SyncAllResult,
|
|
59
|
+
} from "./sync.js";
|
|
60
|
+
|
|
61
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
62
|
+
export type {
|
|
63
|
+
GarminActivity,
|
|
64
|
+
GarminActivityLap,
|
|
65
|
+
GarminActivitySample,
|
|
66
|
+
GarminDailySummary,
|
|
67
|
+
GarminSleep,
|
|
68
|
+
GarminSleepLevel,
|
|
69
|
+
GarminBodyComposition,
|
|
70
|
+
GarminMenstrualCycleData,
|
|
71
|
+
GarminUserProfile,
|
|
72
|
+
GarminActivityType,
|
|
73
|
+
GarminOAuthRequestTokenResponse,
|
|
74
|
+
GarminOAuthAccessTokenResponse,
|
|
75
|
+
GarminWebhookPayload,
|
|
76
|
+
} from "./types.js";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { mapActivityType } from "./activity-type.js";
|
|
3
|
+
|
|
4
|
+
describe("mapActivityType", () => {
|
|
5
|
+
it("maps running types to Terra Running (8)", () => {
|
|
6
|
+
expect(mapActivityType("RUNNING")).toBe(8);
|
|
7
|
+
expect(mapActivityType("INDOOR_RUNNING")).toBe(8);
|
|
8
|
+
expect(mapActivityType("TRAIL_RUNNING")).toBe(8);
|
|
9
|
+
expect(mapActivityType("TREADMILL_RUNNING")).toBe(8);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("maps cycling types to Terra Biking (1)", () => {
|
|
13
|
+
expect(mapActivityType("CYCLING")).toBe(1);
|
|
14
|
+
expect(mapActivityType("INDOOR_CYCLING")).toBe(1);
|
|
15
|
+
expect(mapActivityType("MOUNTAIN_BIKING")).toBe(1);
|
|
16
|
+
expect(mapActivityType("GRAVEL_CYCLING")).toBe(1);
|
|
17
|
+
expect(mapActivityType("VIRTUAL_RIDE")).toBe(1);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("maps WALKING to Terra Walking (7)", () => {
|
|
21
|
+
expect(mapActivityType("WALKING")).toBe(7);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("maps HIKING to Terra Hiking (35)", () => {
|
|
25
|
+
expect(mapActivityType("HIKING")).toBe(35);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("maps swimming types to Terra Swimming (82)", () => {
|
|
29
|
+
expect(mapActivityType("SWIMMING")).toBe(82);
|
|
30
|
+
expect(mapActivityType("OPEN_WATER_SWIMMING")).toBe(82);
|
|
31
|
+
expect(mapActivityType("LAP_SWIMMING")).toBe(82);
|
|
32
|
+
expect(mapActivityType("POOL_SWIMMING")).toBe(82);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("maps snow sports correctly", () => {
|
|
36
|
+
expect(mapActivityType("ALPINE_SKIING")).toBe(66);
|
|
37
|
+
expect(mapActivityType("CROSS_COUNTRY_SKIING")).toBe(67);
|
|
38
|
+
expect(mapActivityType("SNOWBOARDING")).toBe(73);
|
|
39
|
+
expect(mapActivityType("SNOWSHOEING")).toBe(74);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("maps water sports correctly", () => {
|
|
43
|
+
expect(mapActivityType("ROWING")).toBe(53);
|
|
44
|
+
expect(mapActivityType("INDOOR_ROWING")).toBe(53);
|
|
45
|
+
expect(mapActivityType("KAYAKING")).toBe(40);
|
|
46
|
+
expect(mapActivityType("SAILING")).toBe(59);
|
|
47
|
+
expect(mapActivityType("SURFING")).toBe(81);
|
|
48
|
+
expect(mapActivityType("KITESURFING")).toBe(41);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("maps racket sports correctly", () => {
|
|
52
|
+
expect(mapActivityType("TENNIS")).toBe(87);
|
|
53
|
+
expect(mapActivityType("TABLE_TENNIS")).toBe(85);
|
|
54
|
+
expect(mapActivityType("BADMINTON")).toBe(10);
|
|
55
|
+
expect(mapActivityType("SQUASH")).toBe(76);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("maps gym/fitness activities correctly", () => {
|
|
59
|
+
expect(mapActivityType("STRENGTH_TRAINING")).toBe(80);
|
|
60
|
+
expect(mapActivityType("CROSSFIT")).toBe(113);
|
|
61
|
+
expect(mapActivityType("YOGA")).toBe(100);
|
|
62
|
+
expect(mapActivityType("PILATES")).toBe(49);
|
|
63
|
+
expect(mapActivityType("HIIT")).toBe(114);
|
|
64
|
+
expect(mapActivityType("ELLIPTICAL")).toBe(25);
|
|
65
|
+
expect(mapActivityType("STAIR_CLIMBING")).toBe(78);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("maps team sports correctly", () => {
|
|
69
|
+
expect(mapActivityType("SOCCER")).toBe(29);
|
|
70
|
+
expect(mapActivityType("BASKETBALL")).toBe(11);
|
|
71
|
+
expect(mapActivityType("VOLLEYBALL")).toBe(94);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns Terra Other (108) for unknown types", () => {
|
|
75
|
+
expect(mapActivityType("UNKNOWN_SPORT" as never)).toBe(108);
|
|
76
|
+
expect(mapActivityType("" as never)).toBe(108);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// ─── Garmin ActivityType → Terra ActivityType ─────────────────────────────────
|
|
2
|
+
// Maps Garmin activity type strings to Terra's ActivityType numeric enum
|
|
3
|
+
// used by the Soma schema.
|
|
4
|
+
//
|
|
5
|
+
// Garmin values: Garmin Health API activity type strings
|
|
6
|
+
// Terra values: https://docs.tryterra.co/reference/health-and-fitness-api/data-models#activitytype
|
|
7
|
+
|
|
8
|
+
import type { GarminActivityType } from "../types.js";
|
|
9
|
+
|
|
10
|
+
const activityTypeMap: Record<string, number> = {
|
|
11
|
+
// ── Running ─────────────────────────────────────────────────────────────
|
|
12
|
+
// Terra Running = 8
|
|
13
|
+
RUNNING: 8,
|
|
14
|
+
INDOOR_RUNNING: 8,
|
|
15
|
+
TRAIL_RUNNING: 8,
|
|
16
|
+
TREADMILL_RUNNING: 8,
|
|
17
|
+
|
|
18
|
+
// ── Cycling ─────────────────────────────────────────────────────────────
|
|
19
|
+
// Terra Biking = 1
|
|
20
|
+
CYCLING: 1,
|
|
21
|
+
INDOOR_CYCLING: 1,
|
|
22
|
+
MOUNTAIN_BIKING: 1,
|
|
23
|
+
GRAVEL_CYCLING: 1,
|
|
24
|
+
VIRTUAL_RIDE: 1,
|
|
25
|
+
|
|
26
|
+
// ── Walking ─────────────────────────────────────────────────────────────
|
|
27
|
+
// Terra Walking = 7
|
|
28
|
+
WALKING: 7,
|
|
29
|
+
|
|
30
|
+
// ── Hiking ──────────────────────────────────────────────────────────────
|
|
31
|
+
// Terra Hiking = 35
|
|
32
|
+
HIKING: 35,
|
|
33
|
+
|
|
34
|
+
// ── Swimming ────────────────────────────────────────────────────────────
|
|
35
|
+
// Terra Swimming = 82
|
|
36
|
+
SWIMMING: 82,
|
|
37
|
+
OPEN_WATER_SWIMMING: 82,
|
|
38
|
+
LAP_SWIMMING: 82,
|
|
39
|
+
POOL_SWIMMING: 82,
|
|
40
|
+
|
|
41
|
+
// ── Gym / Fitness ───────────────────────────────────────────────────────
|
|
42
|
+
STRENGTH_TRAINING: 80, // Terra Strength Training
|
|
43
|
+
YOGA: 100, // Terra Yoga
|
|
44
|
+
PILATES: 49, // Terra Pilates
|
|
45
|
+
CARDIO: 108, // Terra Other
|
|
46
|
+
ELLIPTICAL: 25, // Terra Elliptical
|
|
47
|
+
STAIR_CLIMBING: 78, // Terra Stair Climbing Machine
|
|
48
|
+
CROSSFIT: 113, // Terra Crossfit
|
|
49
|
+
HIIT: 114, // Terra HIIT
|
|
50
|
+
FITNESS_EQUIPMENT: 108, // Terra Other
|
|
51
|
+
BREATHWORK: 108, // Terra Other
|
|
52
|
+
|
|
53
|
+
// ── Snow Sports ─────────────────────────────────────────────────────────
|
|
54
|
+
CROSS_COUNTRY_SKIING: 67, // Terra Cross Country Skiing
|
|
55
|
+
ALPINE_SKIING: 66, // Terra Alpine Skiing
|
|
56
|
+
SNOWBOARDING: 73, // Terra Snowboarding
|
|
57
|
+
SNOWSHOEING: 74, // Terra Snowshoeing
|
|
58
|
+
|
|
59
|
+
// ── Water Sports ────────────────────────────────────────────────────────
|
|
60
|
+
ROWING: 53, // Terra Rowing
|
|
61
|
+
INDOOR_ROWING: 53, // Terra Rowing
|
|
62
|
+
KAYAKING: 40, // Terra Kayaking
|
|
63
|
+
CANOEING: 22, // Terra Canoeing
|
|
64
|
+
SAILING: 59, // Terra Sailing
|
|
65
|
+
SURFING: 81, // Terra Surfing
|
|
66
|
+
KITESURFING: 41, // Terra Kitesurfing
|
|
67
|
+
WINDSURFING: 99, // Terra Windsurfing
|
|
68
|
+
PADDLEBOARDING: 129, // Terra Paddling
|
|
69
|
+
|
|
70
|
+
// ── Skating ─────────────────────────────────────────────────────────────
|
|
71
|
+
SKATING: 62, // Terra Skating
|
|
72
|
+
INLINE_SKATING: 62, // Terra Skating
|
|
73
|
+
|
|
74
|
+
// ── Racket Sports ───────────────────────────────────────────────────────
|
|
75
|
+
TENNIS: 87, // Terra Tennis
|
|
76
|
+
TABLE_TENNIS: 85, // Terra Table Tennis
|
|
77
|
+
BADMINTON: 10, // Terra Badminton
|
|
78
|
+
RACQUETBALL: 51, // Terra Racquetball
|
|
79
|
+
SQUASH: 76, // Terra Squash
|
|
80
|
+
|
|
81
|
+
// ── Climbing ────────────────────────────────────────────────────────────
|
|
82
|
+
ROCK_CLIMBING: 52, // Terra Rock Climbing
|
|
83
|
+
BOULDERING: 52, // Terra Rock Climbing
|
|
84
|
+
|
|
85
|
+
// ── Team Sports ─────────────────────────────────────────────────────────
|
|
86
|
+
SOCCER: 29, // Terra English Football
|
|
87
|
+
BASKETBALL: 11, // Terra Basketball
|
|
88
|
+
VOLLEYBALL: 94, // Terra Volleyball
|
|
89
|
+
CRICKET: 23, // Terra Cricket
|
|
90
|
+
RUGBY: 57, // Terra Rugby
|
|
91
|
+
|
|
92
|
+
// ── Combat Sports ───────────────────────────────────────────────────────
|
|
93
|
+
BOXING: 16, // Terra Boxing
|
|
94
|
+
MARTIAL_ARTS: 44, // Terra Martial Arts
|
|
95
|
+
|
|
96
|
+
// ── Golf ────────────────────────────────────────────────────────────────
|
|
97
|
+
GOLF: 32, // Terra Golf
|
|
98
|
+
|
|
99
|
+
// ── Accessibility ───────────────────────────────────────────────────────
|
|
100
|
+
HANDCYCLING: 14, // Terra Handbiking
|
|
101
|
+
WHEELCHAIR_PUSH_WALKING: 98, // Terra Wheelchair
|
|
102
|
+
WHEELCHAIR_PUSH_RUNNING: 98, // Terra Wheelchair
|
|
103
|
+
|
|
104
|
+
// ── Other ───────────────────────────────────────────────────────────────
|
|
105
|
+
OTHER: 108, // Terra Other
|
|
106
|
+
MULTI_SPORT: 108, // Terra Other
|
|
107
|
+
FLOOR_CLIMBING: 78, // Terra Stair Climbing Machine
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Map a Garmin activity type string to the Terra ActivityType enum.
|
|
112
|
+
* Returns Terra "Other" (108) for unknown types.
|
|
113
|
+
*/
|
|
114
|
+
export function mapActivityType(activityType: GarminActivityType): number {
|
|
115
|
+
return activityTypeMap[activityType] ?? 108;
|
|
116
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ─── Garmin Sleep Stage → Terra SleepLevel ────────────────────────────────────
|
|
2
|
+
// Maps Garmin sleep stage names to Terra's SleepLevel numeric enum.
|
|
3
|
+
//
|
|
4
|
+
// Garmin provides sleep levels as named map keys in sleepLevelsMap:
|
|
5
|
+
// { deep: [...], light: [...], rem: [...], awake: [...] }
|
|
6
|
+
//
|
|
7
|
+
// Terra SleepLevel: 0=Unknown, 1=Awake, 2=Sleeping, 3=OutOfBed, 4=Light, 5=Deep, 6=REM
|
|
8
|
+
|
|
9
|
+
const sleepLevelMap: Record<string, number> = {
|
|
10
|
+
deep: 5, // Deep → Terra Deep
|
|
11
|
+
light: 4, // Light → Terra Light
|
|
12
|
+
rem: 6, // REM → Terra REM
|
|
13
|
+
awake: 1, // Awake → Terra Awake
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Map a Garmin sleep stage name to the Terra SleepLevel enum.
|
|
18
|
+
* Returns 0 (Unknown) for unrecognized stages.
|
|
19
|
+
*/
|
|
20
|
+
export function mapSleepLevel(stage: string): number {
|
|
21
|
+
return sleepLevelMap[stage] ?? 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// ─── Menstruation Transformer ────────────────────────────────────────────────
|
|
2
|
+
// Transforms Garmin menstrual cycle data into the Soma Menstruation schema shape.
|
|
3
|
+
|
|
4
|
+
import type { GarminMenstrualCycleData } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type MenstruationData = ReturnType<typeof transformMenstruation>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Transform a Garmin menstrual cycle record into a Soma Menstruation document shape.
|
|
10
|
+
*
|
|
11
|
+
* @param data - The Garmin menstrual cycle data from the Health API
|
|
12
|
+
* @returns Soma Menstruation fields (without connectionId/userId)
|
|
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`;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
metadata: {
|
|
27
|
+
start_time: startTime,
|
|
28
|
+
end_time: endTime,
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
menstruation_data: {
|
|
32
|
+
day_in_cycle: data.dayInCycle,
|
|
33
|
+
current_phase: data.currentPhase?.toLowerCase(),
|
|
34
|
+
length_of_current_phase_days: data.lengthOfCurrentPhase,
|
|
35
|
+
period_length_days: data.periodLength,
|
|
36
|
+
predicted_cycle_length_days: data.predictedCycleLength,
|
|
37
|
+
is_predicted_cycle: data.isPredictedCycle != null
|
|
38
|
+
? String(data.isPredictedCycle)
|
|
39
|
+
: undefined,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { transformSleep } from "./sleep.js";
|
|
3
|
+
import type { GarminSleep } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const baseSleep: GarminSleep = {
|
|
6
|
+
userId: "garmin_user_1",
|
|
7
|
+
userAccessToken: "token",
|
|
8
|
+
summaryId: "sleep_001",
|
|
9
|
+
calendarDate: "2023-11-14",
|
|
10
|
+
startTimeInSeconds: 1700000000,
|
|
11
|
+
startTimeOffsetInSeconds: -18000,
|
|
12
|
+
durationInSeconds: 28800, // 8 hours
|
|
13
|
+
validation: "ENHANCED_FINAL",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("transformSleep", () => {
|
|
17
|
+
it("maps metadata correctly", () => {
|
|
18
|
+
const result = transformSleep(baseSleep);
|
|
19
|
+
|
|
20
|
+
expect(result.metadata.summary_id).toBe("sleep_001");
|
|
21
|
+
expect(result.metadata.start_time).toBe("2023-11-14T22:13:20.000Z");
|
|
22
|
+
expect(result.metadata.end_time).toBe("2023-11-15T06:13:20.000Z");
|
|
23
|
+
expect(result.metadata.upload_type).toBe(2); // ENHANCED_FINAL → Automatic
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("maps manual upload type", () => {
|
|
27
|
+
const manual = { ...baseSleep, validation: "MANUAL" as const };
|
|
28
|
+
const result = transformSleep(manual);
|
|
29
|
+
expect(result.metadata.upload_type).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("maps tentative as indeterminate", () => {
|
|
33
|
+
const tentative = { ...baseSleep, validation: "AUTO_TENTATIVE" as const };
|
|
34
|
+
const result = transformSleep(tentative);
|
|
35
|
+
expect(result.metadata.upload_type).toBe(4);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("maps sleep stage durations", () => {
|
|
39
|
+
const withStages: GarminSleep = {
|
|
40
|
+
...baseSleep,
|
|
41
|
+
deepSleepDurationInSeconds: 7200,
|
|
42
|
+
lightSleepDurationInSeconds: 14400,
|
|
43
|
+
remSleepInSeconds: 5400,
|
|
44
|
+
awakeDurationInSeconds: 1800,
|
|
45
|
+
};
|
|
46
|
+
const result = transformSleep(withStages);
|
|
47
|
+
|
|
48
|
+
const durations = result.sleep_durations_data;
|
|
49
|
+
expect(durations.asleep.duration_deep_sleep_state_seconds).toBe(7200);
|
|
50
|
+
expect(durations.asleep.duration_light_sleep_state_seconds).toBe(14400);
|
|
51
|
+
expect(durations.asleep.duration_REM_sleep_state_seconds).toBe(5400);
|
|
52
|
+
expect(durations.asleep.duration_asleep_state_seconds).toBe(27000);
|
|
53
|
+
expect(durations.awake!.duration_awake_state_seconds).toBe(1800);
|
|
54
|
+
expect(durations.other!.duration_in_bed_seconds).toBe(28800);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("maps sleep levels hypnogram", () => {
|
|
58
|
+
const withLevels: GarminSleep = {
|
|
59
|
+
...baseSleep,
|
|
60
|
+
sleepLevelsMap: {
|
|
61
|
+
deep: [
|
|
62
|
+
{ startTimeInSeconds: 1700003600, endTimeInSeconds: 1700007200 },
|
|
63
|
+
],
|
|
64
|
+
light: [
|
|
65
|
+
{ startTimeInSeconds: 1700000000, endTimeInSeconds: 1700003600 },
|
|
66
|
+
],
|
|
67
|
+
rem: [
|
|
68
|
+
{ startTimeInSeconds: 1700010800, endTimeInSeconds: 1700014400 },
|
|
69
|
+
],
|
|
70
|
+
awake: [
|
|
71
|
+
{ startTimeInSeconds: 1700007200, endTimeInSeconds: 1700010800 },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const result = transformSleep(withLevels);
|
|
76
|
+
const samples = result.sleep_durations_data.hypnogram_samples;
|
|
77
|
+
|
|
78
|
+
expect(samples).toBeDefined();
|
|
79
|
+
expect(samples).toHaveLength(4);
|
|
80
|
+
// Sorted by timestamp
|
|
81
|
+
expect(samples![0].level).toBe(4); // Light
|
|
82
|
+
expect(samples![1].level).toBe(5); // Deep
|
|
83
|
+
expect(samples![2].level).toBe(1); // Awake
|
|
84
|
+
expect(samples![3].level).toBe(6); // REM
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("maps respiration data", () => {
|
|
88
|
+
const withResp: GarminSleep = {
|
|
89
|
+
...baseSleep,
|
|
90
|
+
averageRespirationInBreathsPerMinute: 16,
|
|
91
|
+
lowestRespirationInBreathsPerMinute: 12,
|
|
92
|
+
highestRespirationInBreathsPerMinute: 22,
|
|
93
|
+
timeOffsetSleepRespiration: {
|
|
94
|
+
"0": 16,
|
|
95
|
+
"3600": 14,
|
|
96
|
+
"7200": 12,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const result = transformSleep(withResp);
|
|
100
|
+
|
|
101
|
+
expect(result.respiration_data).toBeDefined();
|
|
102
|
+
expect(result.respiration_data!.breaths_data!.avg_breaths_per_min).toBe(16);
|
|
103
|
+
expect(result.respiration_data!.breaths_data!.samples).toHaveLength(3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns undefined respiration_data when no respiration fields", () => {
|
|
107
|
+
const result = transformSleep(baseSleep);
|
|
108
|
+
expect(result.respiration_data).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
});
|