@nativesquare/soma 0.3.0 → 0.5.0

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