catence 0.0.0 → 0.1.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 (106) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +373 -29
  3. package/config.example.json +63 -0
  4. package/dist/contracts/index.d.ts +4 -0
  5. package/dist/contracts/index.js +5 -0
  6. package/dist/contracts/release.d.ts +20 -0
  7. package/dist/contracts/release.js +17 -0
  8. package/dist/contracts/runtime.d.ts +11 -0
  9. package/dist/contracts/runtime.js +2 -0
  10. package/dist/contracts/staging.d.ts +183 -0
  11. package/dist/contracts/staging.js +36 -0
  12. package/dist/contracts/storage.d.ts +15 -0
  13. package/dist/contracts/storage.js +2 -0
  14. package/dist/core/dashboard/snapshot.d.ts +16 -0
  15. package/dist/core/dashboard/snapshot.js +65 -0
  16. package/dist/core/query/activity-discovery.d.ts +21 -0
  17. package/dist/core/query/activity-discovery.js +133 -0
  18. package/dist/core/query/analytics.d.ts +59 -0
  19. package/dist/core/query/analytics.js +395 -0
  20. package/dist/core/query/catalog.d.ts +26 -0
  21. package/dist/core/query/catalog.js +141 -0
  22. package/dist/core/query/fitness.d.ts +40 -0
  23. package/dist/core/query/fitness.js +297 -0
  24. package/dist/core/query/repository.d.ts +19 -0
  25. package/dist/core/query/repository.js +182 -0
  26. package/dist/core/query/sql-guard.d.ts +7 -0
  27. package/dist/core/query/sql-guard.js +121 -0
  28. package/dist/core/query/sql.d.ts +2 -0
  29. package/dist/core/query/sql.js +5 -0
  30. package/dist/core/query/swimming.d.ts +19 -0
  31. package/dist/core/query/swimming.js +184 -0
  32. package/dist/core/query/wellness.d.ts +45 -0
  33. package/dist/core/query/wellness.js +292 -0
  34. package/dist/core/retrieval/index.d.ts +13 -0
  35. package/dist/core/retrieval/index.js +248 -0
  36. package/dist/core/runtime/configuration.d.ts +71 -0
  37. package/dist/core/runtime/configuration.js +106 -0
  38. package/dist/core/runtime/limiter.d.ts +13 -0
  39. package/dist/core/runtime/limiter.js +17 -0
  40. package/dist/elt/application/demo.d.ts +23 -0
  41. package/dist/elt/application/demo.js +165 -0
  42. package/dist/elt/application/management.d.ts +25 -0
  43. package/dist/elt/application/management.js +283 -0
  44. package/dist/elt/ingestion/importer.d.ts +8 -0
  45. package/dist/elt/ingestion/importer.js +87 -0
  46. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  47. package/dist/elt/ingestion/providers/intervals.js +224 -0
  48. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  49. package/dist/elt/ingestion/providers/registry.js +62 -0
  50. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  51. package/dist/elt/ingestion/providers/strava/service.js +365 -0
  52. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  53. package/dist/elt/normalization/activities/linking.js +165 -0
  54. package/dist/elt/normalization/normalizers.d.ts +5 -0
  55. package/dist/elt/normalization/normalizers.js +816 -0
  56. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  57. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  58. package/dist/elt/storage/artifacts.d.ts +13 -0
  59. package/dist/elt/storage/artifacts.js +48 -0
  60. package/dist/elt/storage/database.d.ts +74 -0
  61. package/dist/elt/storage/database.js +296 -0
  62. package/dist/elt/storage/migrations.d.ts +3 -0
  63. package/dist/elt/storage/migrations.js +832 -0
  64. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  65. package/dist/elt/storage/migrations.legacy.js +332 -0
  66. package/dist/elt/storage/sql.d.ts +2 -0
  67. package/dist/elt/storage/sql.js +7 -0
  68. package/dist/elt/storage/write-lock.d.ts +6 -0
  69. package/dist/elt/storage/write-lock.js +28 -0
  70. package/dist/elt/streams.d.ts +24 -0
  71. package/dist/elt/streams.js +62 -0
  72. package/dist/interfaces/cli/commands.d.ts +2 -0
  73. package/dist/interfaces/cli/commands.js +101 -0
  74. package/dist/interfaces/cli/main.d.ts +2 -0
  75. package/dist/interfaces/cli/main.js +3 -0
  76. package/dist/interfaces/http/cli-options.d.ts +9 -0
  77. package/dist/interfaces/http/cli-options.js +81 -0
  78. package/dist/interfaces/http/server.d.ts +14 -0
  79. package/dist/interfaces/http/server.js +169 -0
  80. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  81. package/dist/interfaces/mcp/cli-options.js +36 -0
  82. package/dist/interfaces/mcp/main.d.ts +2 -0
  83. package/dist/interfaces/mcp/main.js +59 -0
  84. package/dist/interfaces/mcp/server.d.ts +8 -0
  85. package/dist/interfaces/mcp/server.js +521 -0
  86. package/dist/runtime/index.d.ts +29 -0
  87. package/dist/runtime/index.js +19 -0
  88. package/docs/architecture.md +100 -0
  89. package/docs/assets/image.png +0 -0
  90. package/docs/mcp-activity-retrieval.md +377 -0
  91. package/package.json +81 -6
  92. package/pyproject.toml +28 -0
  93. package/python/__init__.py +1 -0
  94. package/python/catence/__init__.py +0 -0
  95. package/python/catence/providers/__init__.py +0 -0
  96. package/python/catence/providers/garmin/__init__.py +3 -0
  97. package/python/catence/providers/garmin/cli.py +62 -0
  98. package/python/catence/providers/garmin/contracts.py +23 -0
  99. package/python/catence/providers/garmin/registry.py +48 -0
  100. package/python/catence/providers/garmin/staging.py +87 -0
  101. package/python/catence/providers/garmin/streams.py +195 -0
  102. package/python/catence/providers/garmin/worker.py +450 -0
  103. package/python/catence/providers/strava/__init__.py +3 -0
  104. package/python/catence/providers/strava/cli.py +404 -0
  105. package/python/catence/providers/strava/staging.py +73 -0
  106. package/uv.lock +708 -0
@@ -0,0 +1,816 @@
1
+ import { json } from '../storage/sql.js';
2
+ function object(value) {
3
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
4
+ }
5
+ function objectFromJson(value) {
6
+ if (typeof value !== 'string')
7
+ return object(value);
8
+ try {
9
+ return object(JSON.parse(value));
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ function objectArray(value) {
16
+ return Array.isArray(value) ? value.map(object).filter((item) => Object.keys(item).length > 0) : [];
17
+ }
18
+ function firstString(payload, keys) {
19
+ for (const key of keys) {
20
+ const value = payload[key];
21
+ if (typeof value === 'string' && value.length > 0)
22
+ return value;
23
+ if (typeof value === 'number')
24
+ return String(value);
25
+ }
26
+ return null;
27
+ }
28
+ function firstNumber(payload, keys) {
29
+ for (const key of keys) {
30
+ const value = payload[key];
31
+ if (typeof value === 'number' && Number.isFinite(value))
32
+ return value;
33
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))
34
+ return Number(value);
35
+ }
36
+ return null;
37
+ }
38
+ function firstBoolean(payload, keys) {
39
+ for (const key of keys) {
40
+ const value = payload[key];
41
+ if (typeof value === 'boolean')
42
+ return value;
43
+ if (value === 0 || value === '0' || value === 'false')
44
+ return false;
45
+ if (value === 1 || value === '1' || value === 'true')
46
+ return true;
47
+ }
48
+ return null;
49
+ }
50
+ function garminPoolLengthM(payload) {
51
+ const value = firstNumber(payload, ['poolLength', 'pool_length', 'poolLengthM']);
52
+ if (value === null || value <= 0)
53
+ return null;
54
+ // Garmin's activity summary currently reports its pool length in centimetres
55
+ // (for example 2500 for a 25 m pool). Keep support for a future metre-valued
56
+ // endpoint without treating a 25 m setting as 0.25 m.
57
+ return value >= 100 ? value / 100 : value;
58
+ }
59
+ function swimSourceLabel(payload) {
60
+ const direct = firstString(payload, ['lengthType', 'swimType', 'type', 'label', 'name']);
61
+ return direct ? direct.toLowerCase() : null;
62
+ }
63
+ function datePart(value) {
64
+ if (!value)
65
+ return null;
66
+ const match = value.match(/^\d{4}-\d{2}-\d{2}/);
67
+ return match?.[0] ?? null;
68
+ }
69
+ function providerActivityId(provider, remoteId) {
70
+ return `${provider}:${remoteId}`;
71
+ }
72
+ function activityIdentity(provider, remoteId, payload) {
73
+ const externalId = firstString(payload, provider === 'intervals' ? ['external_id'] : ['externalId']);
74
+ return externalId ? `external:${externalId}` : providerActivityId(provider, remoteId);
75
+ }
76
+ /** Garmin's GMT strings often omit their offset; make their UTC meaning explicit for DuckDB. */
77
+ function garminUtcTimestamp(value) {
78
+ if (!value)
79
+ return null;
80
+ const normalized = value.trim().replace(' ', 'T');
81
+ return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized) ? normalized : `${normalized}Z`;
82
+ }
83
+ function activityFields(provider, payload) {
84
+ const summaryPayload = object(payload.summaryDTO);
85
+ const source = { ...summaryPayload, ...payload };
86
+ const intervals = provider === 'intervals';
87
+ const garminStartAtGmt = firstString(source, ['startTimeGMT']);
88
+ return {
89
+ startAtUtc: intervals
90
+ ? firstString(source, ['start_date'])
91
+ : garminUtcTimestamp(garminStartAtGmt) ?? firstString(source, ['startTimeLocal']),
92
+ startAtLocal: firstString(source, intervals ? ['start_date_local', 'start_date'] : ['startTimeLocal', 'startTimeGMT']),
93
+ timezone: firstString(source, intervals ? ['timezone'] : ['timeZoneUnitDTO']),
94
+ sport: intervals
95
+ ? firstString(source, ['type'])
96
+ : firstString(object(source.activityTypeDTO), ['typeKey']) ?? firstString(object(source.activityType), ['typeKey']) ?? firstString(source, ['activityType', 'activityTypeKey']),
97
+ name: firstString(source, intervals ? ['name'] : ['activityName']),
98
+ externalId: firstString(source, intervals ? ['external_id'] : ['externalId']),
99
+ summary: {
100
+ distanceM: firstNumber(source, ['distance']),
101
+ movingS: firstNumber(source, intervals ? ['moving_time'] : ['movingDuration', 'duration']),
102
+ elapsedS: firstNumber(source, intervals ? ['elapsed_time'] : ['elapsedDuration', 'duration']),
103
+ elevationGainM: firstNumber(source, intervals ? ['total_elevation_gain'] : ['elevationGain']),
104
+ calories: firstNumber(source, ['calories']),
105
+ avgHr: firstNumber(source, intervals ? ['average_heartrate'] : ['averageHR']),
106
+ maxHr: firstNumber(source, intervals ? ['max_heartrate'] : ['maxHR']),
107
+ avgPower: firstNumber(source, intervals ? ['icu_average_watts', 'average_watts'] : ['averagePower']),
108
+ weightedPower: firstNumber(source, intervals ? ['icu_weighted_avg_watts'] : ['normalizedPower']),
109
+ avgCadence: firstNumber(source, ['average_cadence', 'averageRunningCadence']),
110
+ trainingLoad: firstNumber(source, intervals ? ['icu_training_load', 'power_load', 'hr_load'] : ['activityTrainingLoad']),
111
+ rpe: firstNumber(source, intervals ? ['icu_rpe', 'session_rpe', 'perceived_exertion'] : ['perceivedExertion']),
112
+ feel: firstNumber(source, ['feel']),
113
+ },
114
+ };
115
+ }
116
+ const dailyMetricMap = [
117
+ { name: 'resting_hr_bpm', unit: 'bpm', keys: ['restingHR', 'restingHeartRate', 'restingHeartRateInBeatsPerMinute'] },
118
+ { name: 'hrv_ms', unit: 'ms', keys: ['hrv', 'hrvSDNN', 'lastNightAvg'] },
119
+ { name: 'sleep_seconds', unit: 's', keys: ['sleepSecs', 'sleepTimeSeconds', 'sleepTimeInSeconds'] },
120
+ { name: 'sleep_score', unit: 'score', keys: ['sleepScore', 'overallSleepScore'] },
121
+ { name: 'stress', unit: 'score', keys: ['stress', 'averageStressLevel'] },
122
+ { name: 'body_battery', unit: 'score', keys: ['bodyBattery', 'charged', 'bodyBatteryMostRecentValue'] },
123
+ { name: 'readiness', unit: 'score', keys: ['readiness', 'score', 'trainingReadinessScore'] },
124
+ { name: 'spo2_pct', unit: '%', keys: ['spO2', 'spo2', 'averageSpO2'] },
125
+ { name: 'weight_kg', unit: 'kg', keys: ['weight', 'weightInKg'] },
126
+ { name: 'steps', unit: 'count', keys: ['steps', 'totalSteps'] },
127
+ { name: 'respiration_bpm', unit: 'breaths/min', keys: ['respiration', 'avgWakingRespirationValue'] },
128
+ { name: 'total_calories_kcal', unit: 'kcal', keys: ['totalKilocalories', 'totalCalories'] },
129
+ { name: 'active_calories_kcal', unit: 'kcal', keys: ['activeKilocalories'] },
130
+ { name: 'bmr_calories_kcal', unit: 'kcal', keys: ['bmrKilocalories'] },
131
+ { name: 'floors_ascended', unit: 'count', keys: ['floorsAscended'] },
132
+ { name: 'floors_descended', unit: 'count', keys: ['floorsDescended'] },
133
+ { name: 'intensity_minutes_moderate', unit: 'min', keys: ['moderateIntensityMinutes'] },
134
+ { name: 'intensity_minutes_vigorous', unit: 'min', keys: ['vigorousIntensityMinutes'] },
135
+ { name: 'intensity_minutes_total', unit: 'min', keys: ['intensityMinutes', 'totalIntensityMinutes'] },
136
+ { name: 'sleep_deep_seconds', unit: 's', keys: ['deepSleepSeconds'] },
137
+ { name: 'sleep_light_seconds', unit: 's', keys: ['lightSleepSeconds'] },
138
+ { name: 'sleep_rem_seconds', unit: 's', keys: ['remSleepSeconds'] },
139
+ { name: 'sleep_awake_seconds', unit: 's', keys: ['awakeSleepSeconds'] },
140
+ { name: 'nap_seconds', unit: 's', keys: ['napTimeSeconds'] },
141
+ ];
142
+ function snake(value) {
143
+ return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
144
+ }
145
+ function timestamp(value) {
146
+ if (typeof value === 'string' && value.length >= 10)
147
+ return value;
148
+ if (typeof value !== 'number' || !Number.isFinite(value))
149
+ return null;
150
+ const milliseconds = value > 10_000_000_000 ? value : value * 1_000;
151
+ return new Date(milliseconds).toISOString();
152
+ }
153
+ function firstTimestamp(payload, keys) {
154
+ for (const key of keys) {
155
+ const value = timestamp(payload[key]);
156
+ if (value)
157
+ return value;
158
+ }
159
+ return null;
160
+ }
161
+ async function upsertDailyMetric(database, provider, metricDate, metricName, valueNumber, valueText, unit, rawHash) {
162
+ await database.run(`INSERT INTO daily_metrics VALUES ($provider, $metricDate, $metricName, $valueNumber, $valueText, $unit, $rawHash)
163
+ ON CONFLICT (provider, metric_date, metric_name) DO UPDATE SET value_number = excluded.value_number, value_text = excluded.value_text,
164
+ unit = excluded.unit, raw_object_hash = excluded.raw_object_hash`, { provider, metricDate, metricName, valueNumber, valueText, unit, rawHash });
165
+ }
166
+ function nutritionFields(payload) {
167
+ return {
168
+ energyKcal: firstNumber(payload, ['kcalConsumed', 'calories', 'totalCalories', 'energyKcal']),
169
+ carbohydratesG: firstNumber(payload, ['carbohydrates', 'carbs', 'totalCarbs', 'carbohydrateGrams']),
170
+ proteinG: firstNumber(payload, ['protein', 'totalProtein', 'proteinGrams']),
171
+ fatG: firstNumber(payload, ['fatTotal', 'fat', 'totalFat', 'fatGrams']),
172
+ hydrationMl: firstNumber(payload, ['hydrationVolume', 'hydration', 'waterMl', 'totalWater']),
173
+ };
174
+ }
175
+ function nutritionItems(payload) {
176
+ const candidates = ['foodItems', 'items', 'entries', 'foodLogEntries', 'meals'];
177
+ for (const key of candidates) {
178
+ const value = payload[key];
179
+ if (Array.isArray(value))
180
+ return value.map(object).filter((item) => Object.keys(item).length > 0);
181
+ }
182
+ return [];
183
+ }
184
+ export async function importSourceEntity(database, entity) {
185
+ const payload = object(entity.payload);
186
+ const rawHash = entity.rawObjectHash;
187
+ await database.run(`INSERT INTO source_entities
188
+ (provider, entity_type, remote_id, parent_remote_id, occurred_on, source_updated_at, raw_object_hash, payload_json, extension_json)
189
+ VALUES ($provider, $entityType, $remoteId, $parentRemoteId, $occurredOn, $sourceUpdatedAt, $rawHash, $payload, $extension)
190
+ ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET
191
+ parent_remote_id = excluded.parent_remote_id, occurred_on = excluded.occurred_on,
192
+ source_updated_at = excluded.source_updated_at, raw_object_hash = excluded.raw_object_hash,
193
+ payload_json = excluded.payload_json, extension_json = excluded.extension_json, normalized_at = now()`, {
194
+ provider: entity.provider, entityType: entity.entityType, remoteId: entity.remoteId,
195
+ parentRemoteId: entity.parentRemoteId, occurredOn: entity.occurredOn, sourceUpdatedAt: entity.sourceUpdatedAt,
196
+ rawHash, payload: json(payload), extension: json(entity.extension),
197
+ });
198
+ await database.run(`INSERT INTO domain_entities
199
+ (provider, entity_type, remote_id, parent_remote_id, occurred_on, payload_json, extension_json, raw_object_hash)
200
+ VALUES ($provider, $entityType, $remoteId, $parentRemoteId, $occurredOn, $payload, $extension, $rawHash)
201
+ ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET
202
+ parent_remote_id = excluded.parent_remote_id, occurred_on = excluded.occurred_on,
203
+ payload_json = excluded.payload_json, extension_json = excluded.extension_json, raw_object_hash = excluded.raw_object_hash`, {
204
+ provider: entity.provider, entityType: entity.entityType, remoteId: entity.remoteId,
205
+ parentRemoteId: entity.parentRemoteId, occurredOn: entity.occurredOn,
206
+ payload: json(payload), extension: json(entity.extension), rawHash,
207
+ });
208
+ if (entity.entityType === 'profile' || entity.entityType === 'athlete') {
209
+ const accountId = firstString(payload, ['id', 'userProfileNumber', 'profileId']) ?? entity.remoteId;
210
+ await database.insertSourceAccount(entity.provider, accountId, firstString(payload, ['name', 'displayName', 'fullName']), payload);
211
+ }
212
+ if (entity.entityType === 'activity') {
213
+ await importActivity(database, entity.provider, entity.remoteId, payload, rawHash);
214
+ if (entity.provider === 'intervals')
215
+ await importIntervalsAutoSwimSets(database, entity.remoteId, payload, rawHash);
216
+ if (entity.provider === 'garmin') {
217
+ await importGarminCyclingFtpFromActivity(database, entity.remoteId, payload, rawHash);
218
+ await importGarminActivityPerformance(database, entity.remoteId, payload, rawHash);
219
+ }
220
+ }
221
+ if (entity.entityType === 'activity_interval')
222
+ await importActivityIntervals(database, entity.provider, entity.parentRemoteId, payload, rawHash);
223
+ if (entity.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set'].includes(entity.entityType)) {
224
+ await importGarminExplicitSwimLengths(database, entity.parentRemoteId, entity.entityType, payload, rawHash);
225
+ }
226
+ if (entity.entityType === 'wellness' || entity.entityType === 'daily_health')
227
+ await importDailyMetrics(database, entity.provider, entity.remoteId, entity.occurredOn, payload, rawHash);
228
+ if (entity.entityType === 'nutrition_day' || entity.entityType === 'nutrition_log')
229
+ await importNutrition(database, entity.provider, entity.occurredOn, entity.remoteId, payload, rawHash);
230
+ if (entity.provider === 'garmin' && entity.entityType === 'training_metric')
231
+ await importGarminCyclingFtp(database, entity.remoteId, payload, rawHash);
232
+ if (entity.provider === 'garmin' && entity.entityType === 'lactate_threshold')
233
+ await importGarminLactateThreshold(database, entity.remoteId, payload, rawHash);
234
+ if (entity.provider === 'garmin' && entity.entityType === 'functional_threshold_power')
235
+ await importGarminCyclingFtpHistory(database, entity.remoteId, payload, rawHash);
236
+ if (entity.provider === 'garmin' && entity.entityType === 'max_metric')
237
+ await importGarminMaxMetrics(database, entity.remoteId, entity.occurredOn, payload, rawHash);
238
+ if (entity.provider === 'garmin' && entity.entityType === 'fitness_age')
239
+ await importGarminFitnessAge(database, entity.remoteId, entity.occurredOn, payload, rawHash);
240
+ if (entity.provider === 'garmin' && entity.entityType === 'training_status')
241
+ await importGarminTrainingStatus(database, entity.remoteId, entity.occurredOn, payload, rawHash);
242
+ if (entity.provider === 'garmin' && ['endurance_score', 'hill_score', 'running_tolerance', 'race_prediction'].includes(entity.entityType))
243
+ await importGarminNumericSeries(database, entity.entityType, entity.remoteId, entity.occurredOn, payload, rawHash);
244
+ if (entity.provider === 'garmin' && entity.entityType === 'health_event')
245
+ await importHealthSession(database, entity.remoteId, entity.occurredOn, 'daily_event', payload, rawHash);
246
+ if (entity.provider === 'garmin' && entity.entityType === 'activity_power_best')
247
+ await importActivityPowerBest(database, entity.parentRemoteId, payload, rawHash);
248
+ }
249
+ async function importActivity(database, provider, remoteId, payload, rawHash) {
250
+ const fields = activityFields(provider, payload);
251
+ const activityId = activityIdentity(provider, remoteId, payload);
252
+ const activitySourceId = providerActivityId(provider, remoteId);
253
+ await database.run(`INSERT INTO activities (activity_id, started_at_utc, started_at_local, timezone, sport, name, link_state)
254
+ VALUES ($activityId, $startedAtUtc, $startedAtLocal, $timezone, $sport, $name, $linkState)
255
+ ON CONFLICT (activity_id) DO UPDATE SET started_at_utc = excluded.started_at_utc, started_at_local = excluded.started_at_local,
256
+ timezone = excluded.timezone, sport = excluded.sport, name = excluded.name, link_state = excluded.link_state`, { activityId, startedAtUtc: fields.startAtUtc, startedAtLocal: fields.startAtLocal, timezone: fields.timezone, sport: fields.sport, name: fields.name, linkState: fields.externalId ? 'strong_external_id' : 'unlinked' });
257
+ await database.run(`INSERT INTO activity_sources (activity_source_id, activity_id, provider, remote_activity_id, external_id, raw_object_hash)
258
+ VALUES ($activitySourceId, $activityId, $provider, $remoteId, $externalId, $rawHash)
259
+ ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, provider = excluded.provider,
260
+ remote_activity_id = excluded.remote_activity_id, external_id = excluded.external_id, raw_object_hash = excluded.raw_object_hash`, { activitySourceId, activityId, provider, remoteId, externalId: fields.externalId, rawHash });
261
+ await database.run(`INSERT INTO activity_summaries
262
+ (activity_source_id, distance_m, moving_s, elapsed_s, elevation_gain_m, calories, avg_hr, max_hr, avg_power, weighted_power, avg_cadence, training_load, rpe, feel, metrics_json)
263
+ VALUES ($activitySourceId, $distanceM, $movingS, $elapsedS, $elevationGainM, $calories, $avgHr, $maxHr, $avgPower, $weightedPower, $avgCadence, $trainingLoad, $rpe, $feel, $metrics)
264
+ ON CONFLICT (activity_source_id) DO UPDATE SET distance_m = excluded.distance_m, moving_s = excluded.moving_s,
265
+ elapsed_s = excluded.elapsed_s, elevation_gain_m = excluded.elevation_gain_m, calories = excluded.calories,
266
+ avg_hr = excluded.avg_hr, max_hr = excluded.max_hr, avg_power = excluded.avg_power, weighted_power = excluded.weighted_power,
267
+ avg_cadence = excluded.avg_cadence, training_load = excluded.training_load, rpe = excluded.rpe, feel = excluded.feel, metrics_json = excluded.metrics_json`, { activitySourceId, ...fields.summary, metrics: json(payload) });
268
+ }
269
+ function isCyclingSport(sport) {
270
+ return Boolean(sport && /cycl|ride/i.test(sport));
271
+ }
272
+ async function upsertTrainingMetricObservation(database, values) {
273
+ await database.run(`INSERT INTO training_metric_observations
274
+ (observation_id, provider, metric_name, sport, observed_at, value_number, value_text, unit, device_id, dimensions_json, source_type, source_remote_id, activity_source_id, raw_object_hash)
275
+ VALUES ($observationId, 'garmin', $metricName, $sport, $observedAt, $value, $valueText, $unit, $deviceId, $dimensions, $sourceType, $sourceRemoteId, $activitySourceId, $rawHash)
276
+ ON CONFLICT (observation_id) DO UPDATE SET
277
+ sport = excluded.sport, observed_at = excluded.observed_at, value_number = excluded.value_number, value_text = excluded.value_text,
278
+ unit = excluded.unit, device_id = excluded.device_id, dimensions_json = excluded.dimensions_json,
279
+ source_type = excluded.source_type, source_remote_id = excluded.source_remote_id,
280
+ activity_source_id = excluded.activity_source_id, raw_object_hash = excluded.raw_object_hash`, { ...values, value: values.value ?? null, valueText: values.valueText ?? null, unit: values.unit ?? null, deviceId: values.deviceId ?? null, dimensions: json(values.dimensions ?? {}) });
281
+ }
282
+ async function importGarminCyclingFtpFromActivity(database, remoteId, payload, rawHash) {
283
+ const fields = activityFields('garmin', payload);
284
+ const value = firstNumber(payload, ['maxFtp']);
285
+ if (value === null || !fields.startAtUtc || !isCyclingSport(fields.sport))
286
+ return;
287
+ await upsertTrainingMetricObservation(database, {
288
+ observationId: `garmin:cycling_ftp:activity:${remoteId}`,
289
+ metricName: 'cycling_ftp_w', sport: fields.sport ?? 'cycling', observedAt: fields.startAtUtc, value,
290
+ sourceType: 'activity_summary', sourceRemoteId: remoteId, activitySourceId: providerActivityId('garmin', remoteId), rawHash,
291
+ });
292
+ }
293
+ async function importGarminCyclingFtp(database, remoteId, payload, rawHash) {
294
+ const sport = firstString(payload, ['sport']);
295
+ const observedAt = firstString(payload, ['calendarDate']);
296
+ const value = firstNumber(payload, ['functionalThresholdPower']);
297
+ if (sport?.toUpperCase() !== 'CYCLING' || !observedAt || value === null)
298
+ return;
299
+ await upsertTrainingMetricObservation(database, {
300
+ observationId: `garmin:cycling_ftp:source:${remoteId}`,
301
+ metricName: 'cycling_ftp_w', sport: 'cycling', observedAt, value,
302
+ sourceType: 'cycling_ftp', sourceRemoteId: remoteId, activitySourceId: null, rawHash,
303
+ });
304
+ }
305
+ async function importGarminCyclingFtpHistory(database, remoteId, payload, rawHash) {
306
+ const sport = firstString(payload, ['sport', 'series']);
307
+ const observedAt = datePart(firstString(payload, ['calendarDate', 'until', 'date', 'updatedDate', 'from']));
308
+ const value = firstNumber(payload, ['functionalThresholdPower', 'value']);
309
+ if (sport?.toUpperCase() !== 'CYCLING' || !observedAt || value === null)
310
+ return;
311
+ await upsertTrainingMetricObservation(database, {
312
+ observationId: `garmin:cycling_ftp:history:${remoteId}`,
313
+ metricName: 'cycling_ftp_w', sport: 'cycling', observedAt: `${observedAt}T00:00:00Z`, value,
314
+ sourceType: 'cycling_ftp_history', sourceRemoteId: remoteId, activitySourceId: null, rawHash,
315
+ });
316
+ }
317
+ function thresholdObservedAt(payload) {
318
+ return garminUtcTimestamp(firstString(payload, ['calendarDate', 'ftpCreateTime', 'weightCreateTime']));
319
+ }
320
+ async function importGarminLactateThreshold(database, remoteId, payload, rawHash) {
321
+ const speedAndHeartRate = nested(payload, 'speed_and_heart_rate');
322
+ const power = nested(payload, 'power');
323
+ const runningObservedAt = thresholdObservedAt(power) ?? thresholdObservedAt(speedAndHeartRate);
324
+ const heartRateObservedAt = thresholdObservedAt(speedAndHeartRate) ?? runningObservedAt;
325
+ const powerDimensions = {
326
+ origin: firstString(power, ['origin']),
327
+ isStale: typeof power.isStale === 'boolean' ? power.isStale : null,
328
+ weightKg: firstNumber(power, ['weight']),
329
+ powerToWeight: firstNumber(power, ['powerToWeight']),
330
+ ftpCreateTime: firstString(power, ['ftpCreateTime']),
331
+ weightCreateTime: firstString(power, ['weightCreateTime']),
332
+ };
333
+ const sourceType = 'lactate_threshold';
334
+ const runningPower = firstNumber(power, ['functionalThresholdPower', 'value']);
335
+ if (runningObservedAt && runningPower !== null)
336
+ await upsertTrainingMetricObservation(database, {
337
+ observationId: `garmin:lactate_threshold:${remoteId}:running_power`, metricName: 'running_lactate_threshold_power_w', sport: 'running',
338
+ observedAt: runningObservedAt, value: runningPower, unit: 'W', dimensions: powerDimensions,
339
+ sourceType, sourceRemoteId: remoteId, activitySourceId: null, rawHash,
340
+ });
341
+ const powerToWeight = firstNumber(power, ['powerToWeight']);
342
+ if (runningObservedAt && powerToWeight !== null)
343
+ await upsertTrainingMetricObservation(database, {
344
+ observationId: `garmin:lactate_threshold:${remoteId}:running_power_to_weight`, metricName: 'running_lactate_threshold_power_w_kg', sport: 'running',
345
+ observedAt: runningObservedAt, value: powerToWeight, unit: 'W/kg', dimensions: powerDimensions,
346
+ sourceType, sourceRemoteId: remoteId, activitySourceId: null, rawHash,
347
+ });
348
+ const paceEncoding = firstNumber(speedAndHeartRate, ['speed']);
349
+ // Garmin's threshold endpoint names this field `speed`, but its value is
350
+ // minutes per 100 m (for example 0.4222 represents 4:13/km).
351
+ if (heartRateObservedAt && paceEncoding !== null && paceEncoding > 0)
352
+ await upsertTrainingMetricObservation(database, {
353
+ observationId: `garmin:lactate_threshold:${remoteId}:running_pace`, metricName: 'running_lactate_threshold_pace_s_per_km', sport: 'running',
354
+ observedAt: heartRateObservedAt, value: paceEncoding * 600, unit: 's/km', dimensions: { rawPaceEncoding: paceEncoding },
355
+ sourceType, sourceRemoteId: remoteId, activitySourceId: null, rawHash,
356
+ });
357
+ const runningHeartRate = firstNumber(speedAndHeartRate, ['heartRate', 'hearRate']);
358
+ if (heartRateObservedAt && runningHeartRate !== null)
359
+ await upsertTrainingMetricObservation(database, {
360
+ observationId: `garmin:lactate_threshold:${remoteId}:running_heart_rate`, metricName: 'running_lactate_threshold_hr_bpm', sport: 'running',
361
+ observedAt: heartRateObservedAt, value: runningHeartRate, unit: 'bpm', sourceType, sourceRemoteId: remoteId, activitySourceId: null, rawHash,
362
+ });
363
+ const cyclingHeartRate = firstNumber(speedAndHeartRate, ['heartRateCycling']);
364
+ if (heartRateObservedAt && cyclingHeartRate !== null)
365
+ await upsertTrainingMetricObservation(database, {
366
+ observationId: `garmin:lactate_threshold:${remoteId}:cycling_heart_rate`, metricName: 'cycling_lactate_threshold_hr_bpm', sport: 'cycling',
367
+ observedAt: heartRateObservedAt, value: cyclingHeartRate, unit: 'bpm', sourceType, sourceRemoteId: remoteId, activitySourceId: null, rawHash,
368
+ });
369
+ }
370
+ const NON_METRIC_KEYS = new Set(['id', 'userid', 'userprofilepk', 'deviceid', 'calendardate', 'date', 'timestamp', 'timestamplocal', 'starttime', 'endtime']);
371
+ function nested(payload, key) {
372
+ return object(payload[key]);
373
+ }
374
+ function metricObservedAt(occurredOn, payload) {
375
+ return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${occurredOn}T00:00:00Z` : null);
376
+ }
377
+ async function importGarminActivityPerformance(database, remoteId, payload, rawHash) {
378
+ const fields = activityFields('garmin', payload);
379
+ if (!fields.startAtUtc)
380
+ return;
381
+ for (const [metricName, keys, unit] of [
382
+ ['aerobic_training_effect', ['aerobicTrainingEffect'], 'score'],
383
+ ['anaerobic_training_effect', ['anaerobicTrainingEffect'], 'score'],
384
+ ['recovery_time_min', ['recoveryTime'], 'min'],
385
+ ['performance_condition', ['performanceCondition'], 'score'],
386
+ ]) {
387
+ const value = firstNumber(payload, keys);
388
+ if (value === null)
389
+ continue;
390
+ await upsertTrainingMetricObservation(database, {
391
+ observationId: `garmin:activity:${remoteId}:${metricName}`, metricName, sport: fields.sport ?? 'unknown',
392
+ observedAt: fields.startAtUtc, value, unit, sourceType: 'activity_summary', sourceRemoteId: remoteId,
393
+ activitySourceId: providerActivityId('garmin', remoteId), rawHash,
394
+ });
395
+ }
396
+ }
397
+ async function importGarminMaxMetrics(database, remoteId, occurredOn, payload, rawHash) {
398
+ for (const sport of ['generic', 'cycling']) {
399
+ const values = nested(payload, sport);
400
+ const observedAt = metricObservedAt(occurredOn, values);
401
+ if (!observedAt)
402
+ continue;
403
+ const vo2 = firstNumber(values, ['vo2MaxPreciseValue', 'vo2MaxValue']);
404
+ if (vo2 !== null)
405
+ await upsertTrainingMetricObservation(database, {
406
+ observationId: `garmin:max_metrics:${remoteId}:${sport}:vo2_max`, metricName: 'vo2_max_ml_kg_min', sport,
407
+ observedAt, value: vo2, unit: 'ml/kg/min', sourceType: 'max_metrics', sourceRemoteId: remoteId, activitySourceId: null, rawHash,
408
+ });
409
+ const age = firstNumber(values, ['fitnessAge']);
410
+ if (age !== null)
411
+ await upsertTrainingMetricObservation(database, {
412
+ observationId: `garmin:max_metrics:${remoteId}:${sport}:fitness_age`, metricName: 'fitness_age_years', sport,
413
+ observedAt, value: age, unit: 'years', sourceType: 'max_metrics', sourceRemoteId: remoteId, activitySourceId: null, rawHash,
414
+ });
415
+ }
416
+ const acclimation = nested(payload, 'heatAltitudeAcclimation');
417
+ const observedAt = metricObservedAt(occurredOn, acclimation);
418
+ if (!observedAt)
419
+ return;
420
+ for (const [metricName, keys, unit] of [
421
+ ['heat_acclimation_pct', ['heatAcclimationPercentage', 'acclimationPercentage'], '%'],
422
+ ['altitude_acclimation_pct', ['altitudeAcclimation'], '%'],
423
+ ['altitude_m', ['currentAltitude'], 'm'],
424
+ ]) {
425
+ const value = firstNumber(acclimation, keys);
426
+ if (value !== null)
427
+ await upsertTrainingMetricObservation(database, {
428
+ observationId: `garmin:max_metrics:${remoteId}:acclimation:${metricName}`, metricName, sport: 'generic', observedAt,
429
+ value, unit, sourceType: 'max_metrics', sourceRemoteId: remoteId, activitySourceId: null, rawHash,
430
+ });
431
+ }
432
+ }
433
+ async function importGarminFitnessAge(database, remoteId, occurredOn, payload, rawHash) {
434
+ const observedAt = metricObservedAt(occurredOn, payload);
435
+ const value = firstNumber(payload, ['fitnessAge', 'fitnessAgeValue']);
436
+ if (!observedAt || value === null)
437
+ return;
438
+ await upsertTrainingMetricObservation(database, {
439
+ observationId: `garmin:fitness_age:${remoteId}`, metricName: 'fitness_age_years', sport: 'generic', observedAt,
440
+ value, unit: 'years', sourceType: 'fitness_age', sourceRemoteId: remoteId, activitySourceId: null, rawHash,
441
+ });
442
+ }
443
+ function leaves(value, prefix = '', depth = 0) {
444
+ if (depth > 4)
445
+ return [];
446
+ if (typeof value === 'number' && Number.isFinite(value))
447
+ return [{ path: prefix, number: value }];
448
+ if (typeof value === 'string' && value.length > 0)
449
+ return [{ path: prefix, text: value }];
450
+ if (Array.isArray(value))
451
+ return value.flatMap((item, index) => leaves(item, `${prefix}_${index}`, depth + 1));
452
+ if (!value || typeof value !== 'object')
453
+ return [];
454
+ return Object.entries(value).flatMap(([key, item]) => {
455
+ const normalized = snake(key);
456
+ if (NON_METRIC_KEYS.has(normalized))
457
+ return [];
458
+ return leaves(item, prefix ? `${prefix}_${normalized}` : normalized, depth + 1);
459
+ });
460
+ }
461
+ async function importGarminNumericSeries(database, entityType, remoteId, occurredOn, payload, rawHash) {
462
+ const observedAt = metricObservedAt(occurredOn, payload);
463
+ if (!observedAt)
464
+ return;
465
+ const sport = firstString(payload, ['sport', 'fitnessTrendSport']) ?? 'generic';
466
+ const deviceId = firstString(payload, ['deviceId']);
467
+ for (const leaf of leaves(payload)) {
468
+ if (leaf.number === undefined && leaf.text === undefined)
469
+ continue;
470
+ await upsertTrainingMetricObservation(database, {
471
+ observationId: `garmin:${entityType}:${remoteId}:${leaf.path}`, metricName: `${entityType}_${leaf.path}`,
472
+ sport, observedAt, value: leaf.number, valueText: leaf.text, unit: null, deviceId,
473
+ sourceType: entityType, sourceRemoteId: remoteId, activitySourceId: null, rawHash,
474
+ });
475
+ }
476
+ }
477
+ async function importGarminTrainingStatus(database, remoteId, occurredOn, payload, rawHash) {
478
+ const statusMap = nested(nested(payload, 'mostRecentTrainingStatus'), 'latestTrainingStatusData');
479
+ const balanceMap = nested(nested(payload, 'mostRecentTrainingLoadBalance'), 'metricsTrainingLoadBalanceDTOMap');
480
+ for (const [deviceId, values] of Object.entries(statusMap)) {
481
+ const item = object(values);
482
+ await importGarminNumericSeries(database, 'training_status', `${remoteId}:${deviceId}`, occurredOn, { ...item, deviceId }, rawHash);
483
+ }
484
+ for (const [deviceId, values] of Object.entries(balanceMap)) {
485
+ const item = object(values);
486
+ await importGarminNumericSeries(database, 'training_load_balance', `${remoteId}:${deviceId}`, occurredOn, { ...item, deviceId }, rawHash);
487
+ }
488
+ }
489
+ async function importHealthSession(database, remoteId, occurredOn, sessionType, payload, rawHash) {
490
+ const startedAt = firstTimestamp(payload, ['sleepStartTimestampGMT', 'startTimestampGMT', 'eventStartTimeGmt', 'startTimeGMT']);
491
+ const endedAt = firstTimestamp(payload, ['sleepEndTimestampGMT', 'endTimestampGMT', 'endTimeGMT']);
492
+ await database.run(`INSERT INTO health_sessions (session_id, provider, session_type, occurred_on, started_at, ended_at, source_type, source_remote_id, payload_json, raw_object_hash)
493
+ VALUES ($id, 'garmin', $type, $date, $startedAt, $endedAt, $sourceType, $remoteId, $payload, $rawHash)
494
+ ON CONFLICT (session_id) DO UPDATE SET occurred_on = excluded.occurred_on, started_at = excluded.started_at, ended_at = excluded.ended_at,
495
+ payload_json = excluded.payload_json, raw_object_hash = excluded.raw_object_hash`, { id: `garmin:${sessionType}:${remoteId}`, type: sessionType, date: occurredOn, startedAt, endedAt, sourceType: sessionType, remoteId, payload: json(payload), rawHash });
496
+ }
497
+ async function importActivityPowerBest(database, activityRemoteId, payload, rawHash) {
498
+ const duration = firstNumber(payload, ['durationSeconds']);
499
+ const bestPower = firstNumber(payload, ['bestPowerWatts']);
500
+ if (!activityRemoteId || duration === null || bestPower === null)
501
+ return;
502
+ await database.run(`INSERT INTO activity_power_bests VALUES ('garmin', $activitySourceId, $duration, $bestPower, 'garmin_fit_derived', $rawHash)
503
+ ON CONFLICT (provider, activity_source_id, duration_s) DO UPDATE SET best_power_w = excluded.best_power_w,
504
+ source_type = excluded.source_type, raw_object_hash = excluded.raw_object_hash`, { activitySourceId: providerActivityId('garmin', activityRemoteId), duration: Math.round(duration), bestPower, rawHash });
505
+ }
506
+ async function importWellnessSamples(database, remoteId, metricName, unit, values, rawHash) {
507
+ if (!Array.isArray(values))
508
+ return;
509
+ for (const [index, entry] of values.entries()) {
510
+ const row = Array.isArray(entry) ? entry : [];
511
+ const item = object(entry);
512
+ const observedAt = timestamp(row[0] ?? item.timestamp ?? item.time ?? item.startTimestampGMT);
513
+ const value = typeof row[1] === 'number' ? row[1] : firstNumber(item, ['value', 'heartRate', 'stress', 'bodyBattery']);
514
+ if (!observedAt || value === null)
515
+ continue;
516
+ await database.run(`INSERT INTO wellness_samples VALUES ($id, 'garmin', $metricName, $observedAt, $value, $unit, $sourceType, $remoteId, $rawHash)
517
+ ON CONFLICT (sample_id) DO UPDATE SET value_number = excluded.value_number, unit = excluded.unit, raw_object_hash = excluded.raw_object_hash`, { id: `garmin:${remoteId}:${metricName}:${observedAt}:${index}`, metricName, observedAt, value, unit, sourceType: metricName, remoteId, rawHash });
518
+ }
519
+ }
520
+ async function upsertSwimSet(database, activitySourceId, input, rawHash) {
521
+ await database.run(`INSERT INTO swim_sets
522
+ (activity_source_id, source_type, set_index, label, reps, rep_distance_m, total_distance_m, work_s, rest_s, avg_pace, avg_hr, max_hr, stroke_rate, metrics_json, raw_object_hash)
523
+ VALUES ($activitySourceId, $sourceType, $setIndex, $label, $reps, $repDistanceM, $totalDistanceM, $workS, $restS, $avgPace, $avgHr, $maxHr, $strokeRate, $metrics, $rawHash)
524
+ ON CONFLICT (activity_source_id, source_type, set_index) DO UPDATE SET
525
+ label = excluded.label, reps = excluded.reps, rep_distance_m = excluded.rep_distance_m,
526
+ total_distance_m = excluded.total_distance_m, work_s = excluded.work_s, rest_s = excluded.rest_s,
527
+ avg_pace = excluded.avg_pace, avg_hr = excluded.avg_hr, max_hr = excluded.max_hr,
528
+ stroke_rate = excluded.stroke_rate, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, { activitySourceId, ...input, metrics: json(input.metrics), rawHash });
529
+ }
530
+ async function upsertActivityInterval(database, activitySourceId, values) {
531
+ await database.run(`INSERT INTO activity_intervals
532
+ (activity_source_id, interval_key, label, start_s, end_s, distance_m, avg_power, avg_hr, avg_pace, intensity, metrics_json, duration_s, moving_s, source_type)
533
+ VALUES ($activitySourceId, $key, $label, $startS, $endS, $distanceM, $avgPower, $avgHr, $avgPace, $intensity, $metrics, $durationS, $movingS, $sourceType)
534
+ ON CONFLICT (activity_source_id, interval_key) DO UPDATE SET
535
+ label = excluded.label, start_s = excluded.start_s, end_s = excluded.end_s,
536
+ distance_m = excluded.distance_m, avg_power = excluded.avg_power, avg_hr = excluded.avg_hr,
537
+ avg_pace = excluded.avg_pace, intensity = excluded.intensity, metrics_json = excluded.metrics_json,
538
+ duration_s = excluded.duration_s, moving_s = excluded.moving_s, source_type = excluded.source_type`, { activitySourceId, ...values, metrics: json(values.metrics) });
539
+ }
540
+ function parseIntervalsAutoBlock(value) {
541
+ if (typeof value !== 'string')
542
+ return null;
543
+ const rawLabel = value.trim();
544
+ const match = rawLabel.replaceAll('×', 'x').match(/^(\d+)\s*x\s*(\d+(?:\.\d+)?)\s*m(?:\s+(\d+(?:\.\d+)?)\s*bpm)?$/i);
545
+ if (!match)
546
+ return null;
547
+ const reps = Number(match[1]);
548
+ const repDistanceM = Number(match[2]);
549
+ const avgHr = match[3] ? Number(match[3]) : null;
550
+ if (!Number.isInteger(reps) || reps <= 0 || !Number.isFinite(repDistanceM) || repDistanceM <= 0 || (avgHr !== null && !Number.isFinite(avgHr)))
551
+ return null;
552
+ return { rawLabel, reps, repDistanceM, avgHr };
553
+ }
554
+ async function importIntervalsAutoSwimSets(database, remoteId, payload, rawHash) {
555
+ const blocks = Array.isArray(payload.interval_summary) ? payload.interval_summary : [];
556
+ if (blocks.length === 0)
557
+ return;
558
+ const activitySourceId = providerActivityId('intervals', remoteId);
559
+ await database.run("DELETE FROM swim_sets WHERE activity_source_id = $activitySourceId AND source_type = 'intervals_auto'", { activitySourceId });
560
+ await database.run("DELETE FROM activity_intervals WHERE activity_source_id = $activitySourceId AND source_type = 'intervals_auto'", { activitySourceId });
561
+ for (const [index, block] of blocks.entries()) {
562
+ const parsed = parseIntervalsAutoBlock(block);
563
+ if (!parsed)
564
+ continue;
565
+ const totalDistanceM = parsed.reps * parsed.repDistanceM;
566
+ const metrics = { rawLabel: parsed.rawLabel, providerGrouping: 'intervals_auto' };
567
+ await upsertSwimSet(database, activitySourceId, {
568
+ sourceType: 'intervals_auto', setIndex: index, label: parsed.rawLabel,
569
+ reps: parsed.reps, repDistanceM: parsed.repDistanceM, totalDistanceM,
570
+ workS: null, restS: null, avgPace: null, avgHr: parsed.avgHr, maxHr: null, strokeRate: null, metrics,
571
+ }, rawHash);
572
+ await upsertActivityInterval(database, activitySourceId, {
573
+ key: `intervals:auto:${index}`, label: parsed.rawLabel,
574
+ startS: null, endS: null, distanceM: totalDistanceM, avgPower: null, avgHr: parsed.avgHr,
575
+ avgPace: null, intensity: null, durationS: null, movingS: null, sourceType: 'intervals_auto', metrics,
576
+ });
577
+ }
578
+ }
579
+ async function importGarminSplitSummaries(database, parentRemoteId, payload, rawHash) {
580
+ const summaries = objectArray(payload.splitSummaries);
581
+ const activitySourceId = providerActivityId('garmin', parentRemoteId);
582
+ await database.run("DELETE FROM swim_sets WHERE activity_source_id = $activitySourceId AND source_type = 'garmin_detected'", { activitySourceId });
583
+ await database.run("DELETE FROM activity_intervals WHERE activity_source_id = $activitySourceId AND source_type = 'garmin_detected'", { activitySourceId });
584
+ await database.run(`DELETE FROM activity_intervals
585
+ WHERE activity_source_id = $activitySourceId AND source_type IS NULL
586
+ AND start_s IS NULL AND end_s IS NULL AND duration_s IS NULL AND moving_s IS NULL
587
+ AND distance_m IS NULL AND avg_power IS NULL AND avg_hr IS NULL AND avg_pace IS NULL AND intensity IS NULL`, { activitySourceId });
588
+ for (const [index, summary] of summaries.entries()) {
589
+ const label = firstString(summary, ['splitType', 'label', 'name']);
590
+ const durationS = firstNumber(summary, ['duration', 'durationSeconds']);
591
+ const movingS = firstNumber(summary, ['movingDuration', 'movingDurationSeconds']);
592
+ const distanceM = firstNumber(summary, ['distance']);
593
+ const avgHr = firstNumber(summary, ['averageHR', 'averageHeartRate']);
594
+ const maxHr = firstNumber(summary, ['maxHR', 'maxHeartRate']);
595
+ const metrics = { splitSummary: summary, providerGrouping: 'garmin_detected' };
596
+ await upsertActivityInterval(database, activitySourceId, {
597
+ key: `garmin:split_summary:${index}`, label,
598
+ startS: null, endS: null, distanceM, avgPower: null, avgHr, avgPace: null, intensity: null,
599
+ durationS, movingS, sourceType: 'garmin_detected', metrics,
600
+ });
601
+ await upsertSwimSet(database, activitySourceId, {
602
+ sourceType: 'garmin_detected', setIndex: index, label,
603
+ reps: firstNumber(summary, ['noOfSplits', 'repetitions']), repDistanceM: null, totalDistanceM: distanceM,
604
+ workS: movingS, restS: null, avgPace: null, avgHr, maxHr, strokeRate: null, metrics,
605
+ }, rawHash);
606
+ }
607
+ }
608
+ async function importActivityIntervals(database, provider, parentRemoteId, payload, rawHash) {
609
+ if (!parentRemoteId)
610
+ return;
611
+ if (provider === 'garmin' && Array.isArray(payload.splitSummaries)) {
612
+ await importGarminSplitSummaries(database, parentRemoteId, payload, rawHash);
613
+ return;
614
+ }
615
+ const activitySourceId = providerActivityId(provider, parentRemoteId);
616
+ const key = firstString(payload, ['id', 'intervalId', 'start_index']) ?? crypto.randomUUID();
617
+ await upsertActivityInterval(database, activitySourceId, {
618
+ key, label: firstString(payload, ['label', 'name']),
619
+ startS: firstNumber(payload, ['start_secs', 'start_time', 'start_index']), endS: firstNumber(payload, ['end_secs', 'end_time', 'end_index']),
620
+ distanceM: firstNumber(payload, ['distance']), avgPower: firstNumber(payload, ['average_watts', 'averagePower']),
621
+ avgHr: firstNumber(payload, ['average_heartrate', 'averageHR']), avgPace: firstNumber(payload, ['average_pace']),
622
+ intensity: firstNumber(payload, ['intensity']), durationS: firstNumber(payload, ['duration', 'durationSeconds']),
623
+ movingS: firstNumber(payload, ['movingDuration', 'moving_time']), sourceType: `${provider}_interval`, metrics: payload,
624
+ });
625
+ }
626
+ async function activityPoolLengthM(database, activitySourceId) {
627
+ const rows = await database.rows('SELECT metrics_json FROM activity_summaries WHERE activity_source_id = $activitySourceId', { activitySourceId });
628
+ return garminPoolLengthM(objectFromJson(rows[0]?.metrics_json));
629
+ }
630
+ async function importGarminExplicitSwimLengths(database, parentRemoteId, entityType, payload, rawHash) {
631
+ if (!parentRemoteId)
632
+ return;
633
+ const activitySourceId = providerActivityId('garmin', parentRemoteId);
634
+ const summaryPoolLengthM = await activityPoolLengthM(database, activitySourceId);
635
+ for (const field of ['lengths', 'swimLengths', 'lengthSummaries']) {
636
+ const lengths = objectArray(payload[field]);
637
+ if (lengths.length === 0)
638
+ continue;
639
+ const source = `garmin_provider_length:${entityType}:${field}`;
640
+ await database.run('DELETE FROM swim_lengths WHERE activity_source_id = $activitySourceId AND source = $source', { activitySourceId, source });
641
+ for (const [offset, length] of lengths.entries()) {
642
+ const durationS = firstNumber(length, ['duration', 'durationSeconds', 'elapsedDuration']);
643
+ const distanceM = firstNumber(length, ['distance', 'distanceM']);
644
+ // The provider has to explicitly label this object as a length and give
645
+ // at least duration or distance. We never derive lengths from samples.
646
+ if (durationS === null && distanceM === null)
647
+ continue;
648
+ const sourceLabel = swimSourceLabel(length);
649
+ const isRest = firstBoolean(length, ['isRest', 'rest']) ?? Boolean(sourceLabel && /rest|idle|pause/.test(sourceLabel));
650
+ const lengthIndex = Math.round(firstNumber(length, ['lengthIndex', 'lengthNumber', 'index']) ?? offset);
651
+ await database.run(`INSERT INTO swim_lengths
652
+ (activity_source_id, source, length_index, lap_index, pool_length_m, start_time, duration_s, active_duration_s, distance_m, stroke_count, stroke_rate, swolf, avg_hr, max_hr, is_rest, confidence, metrics_json, raw_object_hash)
653
+ VALUES ($activitySourceId, $source, $lengthIndex, $lapIndex, $poolLengthM, $startTime, $durationS, $activeDurationS, $distanceM, $strokeCount, $strokeRate, $swolf, $avgHr, $maxHr, $isRest, 'provider_supplied', $metrics, $rawHash)
654
+ ON CONFLICT (activity_source_id, source, length_index) DO UPDATE SET
655
+ lap_index = excluded.lap_index, pool_length_m = excluded.pool_length_m, start_time = excluded.start_time,
656
+ duration_s = excluded.duration_s, active_duration_s = excluded.active_duration_s, distance_m = excluded.distance_m,
657
+ stroke_count = excluded.stroke_count, stroke_rate = excluded.stroke_rate, swolf = excluded.swolf,
658
+ avg_hr = excluded.avg_hr, max_hr = excluded.max_hr, is_rest = excluded.is_rest,
659
+ confidence = excluded.confidence, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, {
660
+ activitySourceId, source, lengthIndex,
661
+ lapIndex: firstNumber(length, ['lapIndex', 'lapNumber']), poolLengthM: garminPoolLengthM(length) ?? summaryPoolLengthM,
662
+ startTime: timestamp(length.startTime ?? length.startTimestamp ?? length.timestamp), durationS,
663
+ activeDurationS: firstNumber(length, ['activeDuration', 'movingDuration']), distanceM,
664
+ strokeCount: firstNumber(length, ['strokeCount', 'strokes']), strokeRate: firstNumber(length, ['strokeRate', 'averageStrokeRate', 'cadence']),
665
+ swolf: firstNumber(length, ['swolf']), avgHr: firstNumber(length, ['averageHR', 'avgHr']), maxHr: firstNumber(length, ['maxHR', 'maxHr']),
666
+ isRest, metrics: json(length), rawHash,
667
+ });
668
+ }
669
+ }
670
+ }
671
+ async function addActivityQualityFlag(database, activitySourceId, code, severity, details, rawHash) {
672
+ await database.run(`INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
673
+ VALUES ($activitySourceId, $code, $severity, $details, $rawHash)
674
+ ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET
675
+ severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash`, { activitySourceId, code, severity, details: json(details), rawHash });
676
+ }
677
+ /** Re-evaluate source quality whenever an activity is imported or linked. */
678
+ export async function refreshActivityQuality(database, activityId) {
679
+ const sources = await database.rows(`
680
+ SELECT source.activity_source_id, source.provider, source.raw_object_hash, activity.sport,
681
+ summary.distance_m, summary.moving_s, summary.elapsed_s, summary.metrics_json
682
+ FROM activity_sources AS source
683
+ JOIN activities AS activity USING (activity_id)
684
+ LEFT JOIN activity_summaries AS summary USING (activity_source_id)
685
+ WHERE source.activity_id = $activityId
686
+ `, { activityId });
687
+ if (sources.length === 0)
688
+ return;
689
+ await database.run('DELETE FROM activity_quality_flags WHERE activity_source_id IN (SELECT activity_source_id FROM activity_sources WHERE activity_id = $activityId)', { activityId });
690
+ for (const source of sources) {
691
+ if (source.provider !== 'garmin' || source.sport?.toLowerCase() !== 'lap_swimming')
692
+ continue;
693
+ const metrics = objectFromJson(source.metrics_json);
694
+ const poolLengthM = garminPoolLengthM(metrics);
695
+ if (poolLengthM !== null && (poolLengthM < 15 || poolLengthM > 100)) {
696
+ await addActivityQualityFlag(database, source.activity_source_id, 'pool_length_implausible', 'warning', { poolLengthM }, source.raw_object_hash);
697
+ }
698
+ const averageSpeedMps = firstNumber(metrics, ['averageSpeed']);
699
+ const strokeCadenceSpm = firstNumber(metrics, ['averageSwimCadenceInStrokesPerMinute']);
700
+ const durationS = Math.max(source.moving_s ?? 0, source.elapsed_s ?? 0);
701
+ if (durationS >= 900 && averageSpeedMps === 0 && strokeCadenceSpm === 0) {
702
+ await addActivityQualityFlag(database, source.activity_source_id, 'zero_swim_speed_and_cadence', 'warning', {
703
+ averageSpeedMps, strokeCadenceSpm, durationS,
704
+ }, source.raw_object_hash);
705
+ }
706
+ const activeLengths = firstNumber(metrics, ['activeLengths']);
707
+ if (poolLengthM !== null && activeLengths !== null && activeLengths > 0 && source.distance_m !== null && source.distance_m > 0) {
708
+ const expectedDistanceM = activeLengths * poolLengthM;
709
+ if (Math.abs(expectedDistanceM - source.distance_m) > Math.max(poolLengthM, expectedDistanceM * 0.02)) {
710
+ await addActivityQualityFlag(database, source.activity_source_id, 'active_lengths_distance_mismatch', 'warning', {
711
+ activeLengths, poolLengthM, expectedDistanceM, summaryDistanceM: source.distance_m,
712
+ }, source.raw_object_hash);
713
+ }
714
+ }
715
+ const lengths = await database.rows('SELECT count(*) AS count FROM swim_lengths WHERE activity_source_id = $activitySourceId', { activitySourceId: source.activity_source_id });
716
+ if (Number(lengths[0]?.count ?? 0) === 0) {
717
+ await addActivityQualityFlag(database, source.activity_source_id, 'swim_length_data_unavailable', 'info', {
718
+ reason: 'No explicit provider-supplied per-length records were imported.',
719
+ }, source.raw_object_hash);
720
+ }
721
+ }
722
+ const garmin = sources.find((source) => source.provider === 'garmin' && source.distance_m !== null);
723
+ const intervals = sources.find((source) => source.provider === 'intervals' && source.distance_m !== null);
724
+ if (!garmin || !intervals || garmin.distance_m === null || intervals.distance_m === null)
725
+ return;
726
+ const differenceM = Math.abs(garmin.distance_m - intervals.distance_m);
727
+ const toleranceM = Math.max(50, garmin.distance_m * 0.05);
728
+ if (differenceM <= toleranceM)
729
+ return;
730
+ for (const source of [garmin, intervals]) {
731
+ await addActivityQualityFlag(database, source.activity_source_id, 'provider_distance_disagreement', 'warning', {
732
+ garminDistanceM: garmin.distance_m, intervalsDistanceM: intervals.distance_m, differenceM, toleranceM,
733
+ }, source.raw_object_hash);
734
+ }
735
+ }
736
+ async function importGarminDailyDetails(database, remoteId, metricDate, payload, rawHash) {
737
+ const hrv = nested(payload, 'hrvSummary');
738
+ for (const [metricName, keys, unit] of [
739
+ ['hrv_ms', ['lastNightAvg'], 'ms'], ['hrv_weekly_avg_ms', ['weeklyAvg'], 'ms'], ['hrv_last_night_high_ms', ['lastNight5MinHigh'], 'ms'],
740
+ ]) {
741
+ const value = firstNumber(hrv, keys);
742
+ if (value !== null)
743
+ await upsertDailyMetric(database, 'garmin', metricDate, metricName, value, null, unit, rawHash);
744
+ }
745
+ const hrvStatus = firstString(hrv, ['status', 'feedbackPhrase']);
746
+ if (hrvStatus)
747
+ await upsertDailyMetric(database, 'garmin', metricDate, 'hrv_status', null, hrvStatus, null, rawHash);
748
+ await importWellnessSamples(database, remoteId, 'hrv_ms', 'ms', payload.hrvReadings, rawHash);
749
+ await importWellnessSamples(database, remoteId, 'heart_rate_bpm', 'bpm', payload.heartRateValues, rawHash);
750
+ await importWellnessSamples(database, remoteId, 'stress', 'score', payload.stressValuesArray, rawHash);
751
+ await importWellnessSamples(database, remoteId, 'body_battery', 'score', payload.bodyBatteryValuesArray, rawHash);
752
+ const sleep = nested(payload, 'dailySleepDTO');
753
+ if (Object.keys(sleep).length) {
754
+ for (const metric of dailyMetricMap) {
755
+ const value = firstNumber(sleep, metric.keys);
756
+ if (value !== null)
757
+ await upsertDailyMetric(database, 'garmin', metricDate, metric.name, value, null, metric.unit, rawHash);
758
+ }
759
+ await importHealthSession(database, remoteId, metricDate, 'sleep', { ...payload, ...sleep }, rawHash);
760
+ }
761
+ if (payload.event && typeof payload.event === 'object')
762
+ await importHealthSession(database, remoteId, metricDate, 'body_battery_event', payload, rawHash);
763
+ if (payload.inputContext || payload.trainingReadinessScore !== undefined || (payload.score !== undefined && payload.recoveryTime !== undefined)) {
764
+ const observedAt = metricObservedAt(metricDate, payload) ?? `${metricDate}T00:00:00Z`;
765
+ const deviceId = firstString(payload, ['deviceId']);
766
+ for (const leaf of leaves(payload)) {
767
+ if (leaf.number === undefined && leaf.text === undefined)
768
+ continue;
769
+ await upsertTrainingMetricObservation(database, {
770
+ observationId: `garmin:training_readiness:${remoteId}:${leaf.path}`, metricName: `training_readiness_${leaf.path}`,
771
+ sport: 'generic', observedAt, value: leaf.number, valueText: leaf.text, unit: null, deviceId,
772
+ sourceType: 'training_readiness', sourceRemoteId: remoteId, activitySourceId: null, rawHash,
773
+ });
774
+ }
775
+ }
776
+ }
777
+ async function importDailyMetrics(database, provider, remoteId, date, payload, rawHash) {
778
+ const metricDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
779
+ if (!metricDate)
780
+ return;
781
+ for (const metric of dailyMetricMap) {
782
+ const value = firstNumber(payload, metric.keys);
783
+ if (value === null)
784
+ continue;
785
+ await upsertDailyMetric(database, provider, metricDate, metric.name, value, null, metric.unit, rawHash);
786
+ }
787
+ if (provider === 'garmin')
788
+ await importGarminDailyDetails(database, remoteId, metricDate, payload, rawHash);
789
+ }
790
+ async function importNutrition(database, provider, date, remoteId, payload, rawHash) {
791
+ const nutritionDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
792
+ if (!nutritionDate)
793
+ return;
794
+ const fields = nutritionFields(payload);
795
+ await database.run(`INSERT INTO nutrition_days
796
+ (provider, nutrition_date, energy_kcal, carbohydrates_g, protein_g, fat_g, hydration_ml, metrics_json, raw_object_hash)
797
+ VALUES ($provider, $nutritionDate, $energyKcal, $carbohydratesG, $proteinG, $fatG, $hydrationMl, $metrics, $rawHash)
798
+ ON CONFLICT (provider, nutrition_date) DO UPDATE SET energy_kcal = excluded.energy_kcal, carbohydrates_g = excluded.carbohydrates_g,
799
+ protein_g = excluded.protein_g, fat_g = excluded.fat_g, hydration_ml = excluded.hydration_ml, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, { provider, nutritionDate, ...fields, metrics: json(payload), rawHash });
800
+ for (const [index, item] of nutritionItems(payload).entries()) {
801
+ const itemFields = nutritionFields(item);
802
+ const itemId = firstString(item, ['id', 'foodId', 'entryId']) ?? `${remoteId}:${index}`;
803
+ await database.run(`INSERT INTO nutrition_items
804
+ (provider, remote_item_id, nutrition_date, meal, consumed_at, food_name, quantity, energy_kcal, carbohydrates_g, protein_g, fat_g, payload_json, raw_object_hash)
805
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
806
+ ON CONFLICT (provider, remote_item_id) DO UPDATE SET nutrition_date = excluded.nutrition_date, meal = excluded.meal,
807
+ consumed_at = excluded.consumed_at, food_name = excluded.food_name, quantity = excluded.quantity, energy_kcal = excluded.energy_kcal,
808
+ carbohydrates_g = excluded.carbohydrates_g, protein_g = excluded.protein_g, fat_g = excluded.fat_g, payload_json = excluded.payload_json,
809
+ raw_object_hash = excluded.raw_object_hash`, [
810
+ provider, itemId, nutritionDate, firstString(item, ['meal', 'mealName']), firstString(item, ['consumedAt', 'dateTime']),
811
+ firstString(item, ['foodName', 'name', 'description']), firstNumber(item, ['quantity', 'amount']),
812
+ itemFields.energyKcal, itemFields.carbohydratesG, itemFields.proteinG, itemFields.fatG, json(item), rawHash,
813
+ ]);
814
+ }
815
+ }
816
+ //# sourceMappingURL=normalizers.js.map