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.
- package/LICENSE +661 -0
- package/README.md +373 -29
- package/config.example.json +63 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/release.d.ts +20 -0
- package/dist/contracts/release.js +17 -0
- package/dist/contracts/runtime.d.ts +11 -0
- package/dist/contracts/runtime.js +2 -0
- package/dist/contracts/staging.d.ts +183 -0
- package/dist/contracts/staging.js +36 -0
- package/dist/contracts/storage.d.ts +15 -0
- package/dist/contracts/storage.js +2 -0
- package/dist/core/dashboard/snapshot.d.ts +16 -0
- package/dist/core/dashboard/snapshot.js +65 -0
- package/dist/core/query/activity-discovery.d.ts +21 -0
- package/dist/core/query/activity-discovery.js +133 -0
- package/dist/core/query/analytics.d.ts +59 -0
- package/dist/core/query/analytics.js +395 -0
- package/dist/core/query/catalog.d.ts +26 -0
- package/dist/core/query/catalog.js +141 -0
- package/dist/core/query/fitness.d.ts +40 -0
- package/dist/core/query/fitness.js +297 -0
- package/dist/core/query/repository.d.ts +19 -0
- package/dist/core/query/repository.js +182 -0
- package/dist/core/query/sql-guard.d.ts +7 -0
- package/dist/core/query/sql-guard.js +121 -0
- package/dist/core/query/sql.d.ts +2 -0
- package/dist/core/query/sql.js +5 -0
- package/dist/core/query/swimming.d.ts +19 -0
- package/dist/core/query/swimming.js +184 -0
- package/dist/core/query/wellness.d.ts +45 -0
- package/dist/core/query/wellness.js +292 -0
- package/dist/core/retrieval/index.d.ts +13 -0
- package/dist/core/retrieval/index.js +248 -0
- package/dist/core/runtime/configuration.d.ts +71 -0
- package/dist/core/runtime/configuration.js +106 -0
- package/dist/core/runtime/limiter.d.ts +13 -0
- package/dist/core/runtime/limiter.js +17 -0
- package/dist/elt/application/demo.d.ts +23 -0
- package/dist/elt/application/demo.js +165 -0
- package/dist/elt/application/management.d.ts +25 -0
- package/dist/elt/application/management.js +283 -0
- package/dist/elt/ingestion/importer.d.ts +8 -0
- package/dist/elt/ingestion/importer.js +87 -0
- package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
- package/dist/elt/ingestion/providers/intervals.js +224 -0
- package/dist/elt/ingestion/providers/registry.d.ts +19 -0
- package/dist/elt/ingestion/providers/registry.js +62 -0
- package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
- package/dist/elt/ingestion/providers/strava/service.js +365 -0
- package/dist/elt/normalization/activities/linking.d.ts +5 -0
- package/dist/elt/normalization/activities/linking.js +165 -0
- package/dist/elt/normalization/normalizers.d.ts +5 -0
- package/dist/elt/normalization/normalizers.js +816 -0
- package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
- package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
- package/dist/elt/storage/artifacts.d.ts +13 -0
- package/dist/elt/storage/artifacts.js +48 -0
- package/dist/elt/storage/database.d.ts +74 -0
- package/dist/elt/storage/database.js +296 -0
- package/dist/elt/storage/migrations.d.ts +3 -0
- package/dist/elt/storage/migrations.js +832 -0
- package/dist/elt/storage/migrations.legacy.d.ts +12 -0
- package/dist/elt/storage/migrations.legacy.js +332 -0
- package/dist/elt/storage/sql.d.ts +2 -0
- package/dist/elt/storage/sql.js +7 -0
- package/dist/elt/storage/write-lock.d.ts +6 -0
- package/dist/elt/storage/write-lock.js +28 -0
- package/dist/elt/streams.d.ts +24 -0
- package/dist/elt/streams.js +62 -0
- package/dist/interfaces/cli/commands.d.ts +2 -0
- package/dist/interfaces/cli/commands.js +101 -0
- package/dist/interfaces/cli/main.d.ts +2 -0
- package/dist/interfaces/cli/main.js +3 -0
- package/dist/interfaces/http/cli-options.d.ts +9 -0
- package/dist/interfaces/http/cli-options.js +81 -0
- package/dist/interfaces/http/server.d.ts +14 -0
- package/dist/interfaces/http/server.js +169 -0
- package/dist/interfaces/mcp/cli-options.d.ts +6 -0
- package/dist/interfaces/mcp/cli-options.js +36 -0
- package/dist/interfaces/mcp/main.d.ts +2 -0
- package/dist/interfaces/mcp/main.js +59 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +521 -0
- package/dist/runtime/index.d.ts +29 -0
- package/dist/runtime/index.js +19 -0
- package/docs/architecture.md +100 -0
- package/docs/assets/image.png +0 -0
- package/docs/mcp-activity-retrieval.md +377 -0
- package/package.json +81 -6
- package/pyproject.toml +28 -0
- package/python/__init__.py +1 -0
- package/python/catence/__init__.py +0 -0
- package/python/catence/providers/__init__.py +0 -0
- package/python/catence/providers/garmin/__init__.py +3 -0
- package/python/catence/providers/garmin/cli.py +62 -0
- package/python/catence/providers/garmin/contracts.py +23 -0
- package/python/catence/providers/garmin/registry.py +48 -0
- package/python/catence/providers/garmin/staging.py +87 -0
- package/python/catence/providers/garmin/streams.py +195 -0
- package/python/catence/providers/garmin/worker.py +450 -0
- package/python/catence/providers/strava/__init__.py +3 -0
- package/python/catence/providers/strava/cli.py +404 -0
- package/python/catence/providers/strava/staging.py +73 -0
- package/uv.lock +708 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import { migrations as baseMigrations } from './migrations.legacy.js';
|
|
2
|
+
const stravaAndIdentityMigration = {
|
|
3
|
+
version: 6,
|
|
4
|
+
name: 'strava_enrichment_and_activity_links',
|
|
5
|
+
sql: `
|
|
6
|
+
CREATE TABLE IF NOT EXISTS activity_links (
|
|
7
|
+
activity_source_id VARCHAR PRIMARY KEY,
|
|
8
|
+
activity_id VARCHAR NOT NULL,
|
|
9
|
+
method VARCHAR NOT NULL,
|
|
10
|
+
confidence DOUBLE,
|
|
11
|
+
evidence_json JSON NOT NULL DEFAULT '{}',
|
|
12
|
+
linked_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
13
|
+
);
|
|
14
|
+
INSERT INTO activity_links (activity_source_id, activity_id, method, confidence, evidence_json)
|
|
15
|
+
SELECT activity_source_id, activity_id,
|
|
16
|
+
CASE WHEN external_id IS NOT NULL THEN 'strong_external_id' ELSE 'source' END,
|
|
17
|
+
CASE WHEN external_id IS NOT NULL THEN 1.0 ELSE NULL END,
|
|
18
|
+
'{}'
|
|
19
|
+
FROM activity_sources
|
|
20
|
+
ON CONFLICT (activity_source_id) DO NOTHING;
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS strava_enrichment_state (
|
|
23
|
+
resource_type VARCHAR NOT NULL,
|
|
24
|
+
remote_id VARCHAR NOT NULL,
|
|
25
|
+
status VARCHAR NOT NULL,
|
|
26
|
+
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
27
|
+
completed_at TIMESTAMPTZ,
|
|
28
|
+
continuation_page INTEGER,
|
|
29
|
+
detail_json JSON NOT NULL DEFAULT '{}',
|
|
30
|
+
PRIMARY KEY (resource_type, remote_id)
|
|
31
|
+
);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS strava_rate_state (
|
|
33
|
+
account_id VARCHAR PRIMARY KEY,
|
|
34
|
+
read_limit_15m INTEGER,
|
|
35
|
+
read_usage_15m INTEGER,
|
|
36
|
+
read_limit_day INTEGER,
|
|
37
|
+
read_usage_day INTEGER,
|
|
38
|
+
blocked_until TIMESTAMPTZ,
|
|
39
|
+
observed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
40
|
+
);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS strava_segments (
|
|
42
|
+
segment_id VARCHAR PRIMARY KEY,
|
|
43
|
+
name VARCHAR,
|
|
44
|
+
activity_type VARCHAR,
|
|
45
|
+
distance_m DOUBLE,
|
|
46
|
+
average_grade_pct DOUBLE,
|
|
47
|
+
maximum_grade_pct DOUBLE,
|
|
48
|
+
climb_category INTEGER,
|
|
49
|
+
elevation_high_m DOUBLE,
|
|
50
|
+
elevation_low_m DOUBLE,
|
|
51
|
+
total_elevation_gain_m DOUBLE,
|
|
52
|
+
private BOOLEAN,
|
|
53
|
+
hazardous BOOLEAN,
|
|
54
|
+
starred BOOLEAN,
|
|
55
|
+
raw_object_hash VARCHAR,
|
|
56
|
+
payload_json JSON NOT NULL,
|
|
57
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
58
|
+
);
|
|
59
|
+
CREATE TABLE IF NOT EXISTS activity_segments (
|
|
60
|
+
activity_source_id VARCHAR NOT NULL,
|
|
61
|
+
effort_id VARCHAR NOT NULL,
|
|
62
|
+
segment_id VARCHAR NOT NULL,
|
|
63
|
+
elapsed_s DOUBLE,
|
|
64
|
+
moving_s DOUBLE,
|
|
65
|
+
distance_m DOUBLE,
|
|
66
|
+
average_watts DOUBLE,
|
|
67
|
+
average_hr DOUBLE,
|
|
68
|
+
max_hr DOUBLE,
|
|
69
|
+
average_cadence DOUBLE,
|
|
70
|
+
device_watts BOOLEAN,
|
|
71
|
+
pr_rank INTEGER,
|
|
72
|
+
kom_rank INTEGER,
|
|
73
|
+
started_at TIMESTAMPTZ,
|
|
74
|
+
raw_object_hash VARCHAR,
|
|
75
|
+
payload_json JSON NOT NULL,
|
|
76
|
+
PRIMARY KEY (activity_source_id, effort_id)
|
|
77
|
+
);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS activity_segments_segment_idx ON activity_segments (segment_id);
|
|
79
|
+
CREATE TABLE IF NOT EXISTS strava_segment_efforts (
|
|
80
|
+
effort_id VARCHAR PRIMARY KEY,
|
|
81
|
+
segment_id VARCHAR NOT NULL,
|
|
82
|
+
strava_activity_id VARCHAR,
|
|
83
|
+
elapsed_s DOUBLE,
|
|
84
|
+
moving_s DOUBLE,
|
|
85
|
+
distance_m DOUBLE,
|
|
86
|
+
average_watts DOUBLE,
|
|
87
|
+
average_hr DOUBLE,
|
|
88
|
+
max_hr DOUBLE,
|
|
89
|
+
average_cadence DOUBLE,
|
|
90
|
+
device_watts BOOLEAN,
|
|
91
|
+
pr_rank INTEGER,
|
|
92
|
+
kom_rank INTEGER,
|
|
93
|
+
started_at TIMESTAMPTZ,
|
|
94
|
+
raw_object_hash VARCHAR,
|
|
95
|
+
payload_json JSON NOT NULL,
|
|
96
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
97
|
+
);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS strava_segment_efforts_segment_idx ON strava_segment_efforts (segment_id, started_at);
|
|
99
|
+
|
|
100
|
+
CREATE OR REPLACE VIEW canonical_activity_training AS
|
|
101
|
+
WITH ranked AS (
|
|
102
|
+
SELECT a.*, source.activity_source_id, source.provider, source.remote_activity_id, source.raw_object_hash,
|
|
103
|
+
summary.*, row_number() OVER (
|
|
104
|
+
PARTITION BY a.activity_id
|
|
105
|
+
ORDER BY CASE source.provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END
|
|
106
|
+
) AS source_rank
|
|
107
|
+
FROM activities a
|
|
108
|
+
JOIN activity_sources source USING (activity_id)
|
|
109
|
+
LEFT JOIN activity_summaries summary USING (activity_source_id)
|
|
110
|
+
), intervals_analysis AS (
|
|
111
|
+
SELECT source.activity_id,
|
|
112
|
+
max(summary.training_load) AS intervals_training_load,
|
|
113
|
+
max(summary.rpe) AS intervals_rpe,
|
|
114
|
+
max(summary.feel) AS intervals_feel,
|
|
115
|
+
max(summary.weighted_power) AS intervals_weighted_power
|
|
116
|
+
FROM activity_sources source
|
|
117
|
+
JOIN activity_summaries summary USING (activity_source_id)
|
|
118
|
+
WHERE source.provider = 'intervals'
|
|
119
|
+
GROUP BY source.activity_id
|
|
120
|
+
)
|
|
121
|
+
SELECT ranked.* EXCLUDE (source_rank),
|
|
122
|
+
intervals_analysis.intervals_training_load,
|
|
123
|
+
intervals_analysis.intervals_rpe,
|
|
124
|
+
intervals_analysis.intervals_feel,
|
|
125
|
+
intervals_analysis.intervals_weighted_power
|
|
126
|
+
FROM ranked
|
|
127
|
+
LEFT JOIN intervals_analysis USING (activity_id)
|
|
128
|
+
WHERE source_rank = 1;
|
|
129
|
+
|
|
130
|
+
CREATE OR REPLACE VIEW segment_effort_history AS
|
|
131
|
+
SELECT effort.*, segment.name AS segment_name, segment.average_grade_pct, segment.climb_category,
|
|
132
|
+
CASE WHEN effort.elapsed_s > 0 AND effort.distance_m IS NOT NULL THEN effort.distance_m / effort.elapsed_s ELSE NULL END AS average_speed_mps,
|
|
133
|
+
segment.raw_object_hash AS segment_raw_object_hash
|
|
134
|
+
FROM strava_segment_efforts effort
|
|
135
|
+
LEFT JOIN strava_segments segment USING (segment_id);
|
|
136
|
+
CREATE OR REPLACE VIEW strava_gear AS
|
|
137
|
+
SELECT provider, remote_id AS gear_id, payload_json, extension_json, raw_object_hash
|
|
138
|
+
FROM domain_entities WHERE provider = 'strava' AND entity_type = 'gear';
|
|
139
|
+
`,
|
|
140
|
+
};
|
|
141
|
+
const garminPrimaryAndTrainingMetricsMigration = {
|
|
142
|
+
version: 7,
|
|
143
|
+
name: 'garmin_primary_activity_links_and_training_metric_history',
|
|
144
|
+
sql: `
|
|
145
|
+
CREATE TABLE IF NOT EXISTS training_metric_observations (
|
|
146
|
+
observation_id VARCHAR PRIMARY KEY,
|
|
147
|
+
provider VARCHAR NOT NULL,
|
|
148
|
+
metric_name VARCHAR NOT NULL,
|
|
149
|
+
sport VARCHAR NOT NULL,
|
|
150
|
+
observed_at TIMESTAMPTZ NOT NULL,
|
|
151
|
+
value_number DOUBLE NOT NULL,
|
|
152
|
+
source_type VARCHAR NOT NULL,
|
|
153
|
+
source_remote_id VARCHAR NOT NULL,
|
|
154
|
+
activity_source_id VARCHAR,
|
|
155
|
+
raw_object_hash VARCHAR
|
|
156
|
+
);
|
|
157
|
+
CREATE INDEX IF NOT EXISTS training_metric_observations_metric_idx
|
|
158
|
+
ON training_metric_observations (provider, metric_name, sport, observed_at);
|
|
159
|
+
|
|
160
|
+
-- Intervals preserves the originating Garmin activity ID as external_id.
|
|
161
|
+
-- Attach it to the actual Garmin source so the canonical view selects Garmin
|
|
162
|
+
-- while retaining Intervals' separately named analysis values.
|
|
163
|
+
UPDATE activity_sources AS intervals
|
|
164
|
+
SET activity_id = garmin.activity_id
|
|
165
|
+
FROM activity_sources AS garmin
|
|
166
|
+
WHERE intervals.provider = 'intervals'
|
|
167
|
+
AND garmin.provider = 'garmin'
|
|
168
|
+
AND intervals.external_id = garmin.remote_activity_id
|
|
169
|
+
AND NOT EXISTS (
|
|
170
|
+
SELECT 1 FROM activity_links link
|
|
171
|
+
WHERE link.activity_source_id = intervals.activity_source_id
|
|
172
|
+
AND link.method = 'manual'
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
INSERT INTO activity_links (activity_source_id, activity_id, method, confidence, evidence_json)
|
|
176
|
+
SELECT intervals.activity_source_id, garmin.activity_id, 'strong_external_id', 1.0,
|
|
177
|
+
json_object('matchedActivitySourceId', garmin.activity_source_id, 'externalId', intervals.external_id)
|
|
178
|
+
FROM activity_sources AS intervals
|
|
179
|
+
JOIN activity_sources AS garmin
|
|
180
|
+
ON garmin.provider = 'garmin'
|
|
181
|
+
AND intervals.external_id = garmin.remote_activity_id
|
|
182
|
+
WHERE intervals.provider = 'intervals'
|
|
183
|
+
ON CONFLICT (activity_source_id) DO UPDATE SET
|
|
184
|
+
activity_id = CASE WHEN activity_links.method = 'manual' THEN activity_links.activity_id ELSE excluded.activity_id END,
|
|
185
|
+
method = CASE WHEN activity_links.method = 'manual' THEN activity_links.method ELSE excluded.method END,
|
|
186
|
+
confidence = CASE WHEN activity_links.method = 'manual' THEN activity_links.confidence ELSE excluded.confidence END,
|
|
187
|
+
evidence_json = CASE WHEN activity_links.method = 'manual' THEN activity_links.evidence_json ELSE excluded.evidence_json END,
|
|
188
|
+
linked_at = CASE WHEN activity_links.method = 'manual' THEN activity_links.linked_at ELSE now() END;
|
|
189
|
+
|
|
190
|
+
DELETE FROM activities AS activity
|
|
191
|
+
WHERE NOT EXISTS (SELECT 1 FROM activity_sources AS source WHERE source.activity_id = activity.activity_id);
|
|
192
|
+
|
|
193
|
+
-- Garmin's cycling activity summary retains the FTP active during that
|
|
194
|
+
-- activity. These observations reconstruct history already present in an
|
|
195
|
+
-- imported store without relying on Intervals' derived configuration.
|
|
196
|
+
INSERT INTO training_metric_observations
|
|
197
|
+
(observation_id, provider, metric_name, sport, observed_at, value_number, source_type, source_remote_id, activity_source_id, raw_object_hash)
|
|
198
|
+
SELECT
|
|
199
|
+
'garmin:cycling_ftp:activity:' || source.remote_activity_id,
|
|
200
|
+
'garmin', 'cycling_ftp_w', coalesce(activity.sport, json_extract_string(summary.metrics_json, '$.activityType.typeKey'), 'cycling'), activity.started_at_utc,
|
|
201
|
+
try_cast(json_extract_string(summary.metrics_json, '$.maxFtp') AS DOUBLE),
|
|
202
|
+
'activity_summary', source.remote_activity_id, source.activity_source_id, source.raw_object_hash
|
|
203
|
+
FROM activity_sources AS source
|
|
204
|
+
JOIN activities AS activity USING (activity_id)
|
|
205
|
+
JOIN activity_summaries AS summary USING (activity_source_id)
|
|
206
|
+
WHERE source.provider = 'garmin'
|
|
207
|
+
AND activity.started_at_utc IS NOT NULL
|
|
208
|
+
AND (lower(coalesce(activity.sport, json_extract_string(summary.metrics_json, '$.activityType.typeKey'), '')) LIKE '%cycl%' OR lower(coalesce(activity.sport, json_extract_string(summary.metrics_json, '$.activityType.typeKey'), '')) LIKE '%ride%')
|
|
209
|
+
AND try_cast(json_extract_string(summary.metrics_json, '$.maxFtp') AS DOUBLE) IS NOT NULL
|
|
210
|
+
ON CONFLICT (observation_id) DO UPDATE SET
|
|
211
|
+
observed_at = excluded.observed_at,
|
|
212
|
+
value_number = excluded.value_number,
|
|
213
|
+
sport = excluded.sport,
|
|
214
|
+
raw_object_hash = excluded.raw_object_hash;
|
|
215
|
+
|
|
216
|
+
INSERT INTO training_metric_observations
|
|
217
|
+
(observation_id, provider, metric_name, sport, observed_at, value_number, source_type, source_remote_id, activity_source_id, raw_object_hash)
|
|
218
|
+
SELECT
|
|
219
|
+
'garmin:cycling_ftp:source:' || entity.remote_id,
|
|
220
|
+
'garmin', 'cycling_ftp_w', 'cycling',
|
|
221
|
+
cast(json_extract_string(entity.payload_json, '$.calendarDate') AS TIMESTAMPTZ),
|
|
222
|
+
try_cast(json_extract_string(entity.payload_json, '$.functionalThresholdPower') AS DOUBLE),
|
|
223
|
+
'cycling_ftp', entity.remote_id, NULL, entity.raw_object_hash
|
|
224
|
+
FROM source_entities AS entity
|
|
225
|
+
WHERE entity.provider = 'garmin'
|
|
226
|
+
AND entity.entity_type = 'training_metric'
|
|
227
|
+
AND upper(coalesce(json_extract_string(entity.payload_json, '$.sport'), '')) = 'CYCLING'
|
|
228
|
+
AND json_extract_string(entity.payload_json, '$.calendarDate') IS NOT NULL
|
|
229
|
+
AND try_cast(json_extract_string(entity.payload_json, '$.functionalThresholdPower') AS DOUBLE) IS NOT NULL
|
|
230
|
+
ON CONFLICT (observation_id) DO UPDATE SET
|
|
231
|
+
observed_at = excluded.observed_at,
|
|
232
|
+
value_number = excluded.value_number,
|
|
233
|
+
raw_object_hash = excluded.raw_object_hash;
|
|
234
|
+
`,
|
|
235
|
+
};
|
|
236
|
+
const garminActivitySportAndFtpBackfillMigration = {
|
|
237
|
+
version: 8,
|
|
238
|
+
name: 'garmin_activity_sport_and_ftp_history_backfill',
|
|
239
|
+
sql: `
|
|
240
|
+
UPDATE activities AS activity
|
|
241
|
+
SET sport = coalesce(
|
|
242
|
+
json_extract_string(summary.metrics_json, '$.activityType.typeKey'),
|
|
243
|
+
json_extract_string(summary.metrics_json, '$.activityTypeDTO.typeKey')
|
|
244
|
+
)
|
|
245
|
+
FROM activity_sources AS source
|
|
246
|
+
JOIN activity_summaries AS summary USING (activity_source_id)
|
|
247
|
+
WHERE source.provider = 'garmin'
|
|
248
|
+
AND source.activity_id = activity.activity_id
|
|
249
|
+
AND activity.sport IS NULL;
|
|
250
|
+
|
|
251
|
+
INSERT INTO training_metric_observations
|
|
252
|
+
(observation_id, provider, metric_name, sport, observed_at, value_number, source_type, source_remote_id, activity_source_id, raw_object_hash)
|
|
253
|
+
SELECT
|
|
254
|
+
'garmin:cycling_ftp:activity:' || source.remote_activity_id,
|
|
255
|
+
'garmin', 'cycling_ftp_w', coalesce(activity.sport, 'cycling'), activity.started_at_utc,
|
|
256
|
+
try_cast(json_extract_string(summary.metrics_json, '$.maxFtp') AS DOUBLE),
|
|
257
|
+
'activity_summary', source.remote_activity_id, source.activity_source_id, source.raw_object_hash
|
|
258
|
+
FROM activity_sources AS source
|
|
259
|
+
JOIN activities AS activity USING (activity_id)
|
|
260
|
+
JOIN activity_summaries AS summary USING (activity_source_id)
|
|
261
|
+
WHERE source.provider = 'garmin'
|
|
262
|
+
AND activity.started_at_utc IS NOT NULL
|
|
263
|
+
AND (lower(coalesce(activity.sport, '')) LIKE '%cycl%' OR lower(coalesce(activity.sport, '')) LIKE '%ride%')
|
|
264
|
+
AND try_cast(json_extract_string(summary.metrics_json, '$.maxFtp') AS DOUBLE) IS NOT NULL
|
|
265
|
+
ON CONFLICT (observation_id) DO UPDATE SET
|
|
266
|
+
observed_at = excluded.observed_at,
|
|
267
|
+
value_number = excluded.value_number,
|
|
268
|
+
sport = excluded.sport,
|
|
269
|
+
raw_object_hash = excluded.raw_object_hash;
|
|
270
|
+
|
|
271
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
272
|
+
`,
|
|
273
|
+
};
|
|
274
|
+
const garminPrimaryDailyHealthMigration = {
|
|
275
|
+
version: 9,
|
|
276
|
+
name: 'garmin_primary_daily_health',
|
|
277
|
+
sql: `
|
|
278
|
+
-- Keep all provider observations in daily_metrics. The user-facing health
|
|
279
|
+
-- projection selects one coherent source per date so Garmin does not get
|
|
280
|
+
-- silently mixed with values that Intervals derived from the same device.
|
|
281
|
+
CREATE OR REPLACE VIEW daily_health AS
|
|
282
|
+
WITH provider_health AS (
|
|
283
|
+
SELECT provider, metric_date,
|
|
284
|
+
max(value_number) FILTER (WHERE metric_name = 'resting_hr_bpm') AS resting_hr_bpm,
|
|
285
|
+
max(value_number) FILTER (WHERE metric_name = 'hrv_ms') AS hrv_ms,
|
|
286
|
+
max(value_number) FILTER (WHERE metric_name = 'sleep_seconds') AS sleep_seconds,
|
|
287
|
+
max(value_number) FILTER (WHERE metric_name = 'sleep_score') AS sleep_score,
|
|
288
|
+
max(value_number) FILTER (WHERE metric_name = 'stress') AS stress,
|
|
289
|
+
max(value_number) FILTER (WHERE metric_name = 'body_battery') AS body_battery,
|
|
290
|
+
max(value_number) FILTER (WHERE metric_name = 'readiness') AS readiness,
|
|
291
|
+
max(value_number) FILTER (WHERE metric_name = 'spo2_pct') AS spo2_pct,
|
|
292
|
+
max(value_number) FILTER (WHERE metric_name = 'weight_kg') AS weight_kg,
|
|
293
|
+
max(value_number) FILTER (WHERE metric_name = 'steps') AS steps
|
|
294
|
+
FROM daily_metrics
|
|
295
|
+
GROUP BY provider, metric_date
|
|
296
|
+
), ranked AS (
|
|
297
|
+
SELECT provider_health.*,
|
|
298
|
+
row_number() OVER (
|
|
299
|
+
PARTITION BY metric_date
|
|
300
|
+
ORDER BY CASE provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, provider
|
|
301
|
+
) AS source_rank
|
|
302
|
+
FROM provider_health
|
|
303
|
+
)
|
|
304
|
+
SELECT * EXCLUDE (source_rank)
|
|
305
|
+
FROM ranked
|
|
306
|
+
WHERE source_rank = 1;
|
|
307
|
+
`,
|
|
308
|
+
};
|
|
309
|
+
const garminHistoricalMetricsAndStreamsMigration = {
|
|
310
|
+
version: 10,
|
|
311
|
+
name: 'garmin_historical_metrics_and_detailed_streams',
|
|
312
|
+
sql: `
|
|
313
|
+
DROP INDEX IF EXISTS training_metric_observations_metric_idx;
|
|
314
|
+
ALTER TABLE training_metric_observations ALTER COLUMN value_number DROP NOT NULL;
|
|
315
|
+
ALTER TABLE training_metric_observations ADD COLUMN IF NOT EXISTS value_text VARCHAR;
|
|
316
|
+
ALTER TABLE training_metric_observations ADD COLUMN IF NOT EXISTS unit VARCHAR;
|
|
317
|
+
ALTER TABLE training_metric_observations ADD COLUMN IF NOT EXISTS device_id VARCHAR;
|
|
318
|
+
ALTER TABLE training_metric_observations ADD COLUMN IF NOT EXISTS dimensions_json JSON;
|
|
319
|
+
|
|
320
|
+
CREATE TABLE IF NOT EXISTS wellness_samples (
|
|
321
|
+
sample_id VARCHAR PRIMARY KEY,
|
|
322
|
+
provider VARCHAR NOT NULL,
|
|
323
|
+
metric_name VARCHAR NOT NULL,
|
|
324
|
+
observed_at TIMESTAMPTZ NOT NULL,
|
|
325
|
+
value_number DOUBLE NOT NULL,
|
|
326
|
+
unit VARCHAR,
|
|
327
|
+
source_type VARCHAR NOT NULL,
|
|
328
|
+
source_remote_id VARCHAR NOT NULL,
|
|
329
|
+
raw_object_hash VARCHAR
|
|
330
|
+
);
|
|
331
|
+
CREATE INDEX IF NOT EXISTS wellness_samples_metric_idx
|
|
332
|
+
ON wellness_samples (provider, metric_name, observed_at);
|
|
333
|
+
|
|
334
|
+
CREATE TABLE IF NOT EXISTS health_sessions (
|
|
335
|
+
session_id VARCHAR PRIMARY KEY,
|
|
336
|
+
provider VARCHAR NOT NULL,
|
|
337
|
+
session_type VARCHAR NOT NULL,
|
|
338
|
+
occurred_on DATE,
|
|
339
|
+
started_at TIMESTAMPTZ,
|
|
340
|
+
ended_at TIMESTAMPTZ,
|
|
341
|
+
source_type VARCHAR NOT NULL,
|
|
342
|
+
source_remote_id VARCHAR NOT NULL,
|
|
343
|
+
payload_json JSON NOT NULL,
|
|
344
|
+
raw_object_hash VARCHAR
|
|
345
|
+
);
|
|
346
|
+
CREATE INDEX IF NOT EXISTS health_sessions_date_idx
|
|
347
|
+
ON health_sessions (provider, session_type, occurred_on);
|
|
348
|
+
|
|
349
|
+
CREATE TABLE IF NOT EXISTS activity_power_bests (
|
|
350
|
+
provider VARCHAR NOT NULL,
|
|
351
|
+
activity_source_id VARCHAR NOT NULL,
|
|
352
|
+
duration_s INTEGER NOT NULL,
|
|
353
|
+
best_power_w DOUBLE NOT NULL,
|
|
354
|
+
source_type VARCHAR NOT NULL,
|
|
355
|
+
raw_object_hash VARCHAR,
|
|
356
|
+
PRIMARY KEY (provider, activity_source_id, duration_s)
|
|
357
|
+
);
|
|
358
|
+
CREATE OR REPLACE VIEW power_best_facts AS
|
|
359
|
+
SELECT best.*, source.activity_id, activity.started_at_utc, activity.sport
|
|
360
|
+
FROM activity_power_bests AS best
|
|
361
|
+
JOIN activity_sources AS source USING (activity_source_id)
|
|
362
|
+
JOIN activities AS activity USING (activity_id);
|
|
363
|
+
|
|
364
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
365
|
+
`,
|
|
366
|
+
// DuckDB 1.4.4's Node binding can abort when this index rebuild shares the
|
|
367
|
+
// commit transaction with the populated-table ALTERs above. Run it after
|
|
368
|
+
// that durable migration transaction instead; IF NOT EXISTS makes retries
|
|
369
|
+
// safe across subsequent opens.
|
|
370
|
+
postCommitSql: `
|
|
371
|
+
CREATE INDEX IF NOT EXISTS training_metric_observations_metric_idx
|
|
372
|
+
ON training_metric_observations (provider, metric_name, sport, observed_at);
|
|
373
|
+
`,
|
|
374
|
+
};
|
|
375
|
+
const garminUtcActivityTimestampMigration = {
|
|
376
|
+
version: 11,
|
|
377
|
+
name: 'garmin_gmt_activity_timestamps_are_utc',
|
|
378
|
+
sql: `
|
|
379
|
+
-- Garmin labels these fields GMT but commonly omits the offset. Without an
|
|
380
|
+
-- explicit Z DuckDB treats them as the host's local time when binding them
|
|
381
|
+
-- to TIMESTAMPTZ, shifting activity matching by the local UTC offset.
|
|
382
|
+
UPDATE activities AS activity
|
|
383
|
+
SET started_at_utc = try_cast(replace(gmt_value, ' ', 'T') || 'Z' AS TIMESTAMPTZ)
|
|
384
|
+
FROM activity_sources AS source
|
|
385
|
+
JOIN source_entities AS entity
|
|
386
|
+
ON entity.provider = source.provider
|
|
387
|
+
AND entity.entity_type = 'activity'
|
|
388
|
+
AND entity.remote_id = source.remote_activity_id
|
|
389
|
+
CROSS JOIN LATERAL (
|
|
390
|
+
SELECT coalesce(
|
|
391
|
+
json_extract_string(entity.payload_json, '$.startTimeGMT'),
|
|
392
|
+
json_extract_string(entity.payload_json, '$.summaryDTO.startTimeGMT')
|
|
393
|
+
) AS gmt_value
|
|
394
|
+
) AS timestamp_source
|
|
395
|
+
WHERE source.provider = 'garmin'
|
|
396
|
+
AND source.activity_id = activity.activity_id
|
|
397
|
+
AND gmt_value IS NOT NULL
|
|
398
|
+
AND NOT regexp_matches(gmt_value, '(Z|[+-][0-9]{2}:?[0-9]{2})$');
|
|
399
|
+
|
|
400
|
+
UPDATE training_metric_observations AS observation
|
|
401
|
+
SET observed_at = try_cast(replace(gmt_value, ' ', 'T') || 'Z' AS TIMESTAMPTZ)
|
|
402
|
+
FROM source_entities AS entity
|
|
403
|
+
CROSS JOIN LATERAL (
|
|
404
|
+
SELECT coalesce(
|
|
405
|
+
json_extract_string(entity.payload_json, '$.startTimeGMT'),
|
|
406
|
+
json_extract_string(entity.payload_json, '$.summaryDTO.startTimeGMT')
|
|
407
|
+
) AS gmt_value
|
|
408
|
+
) AS timestamp_source
|
|
409
|
+
WHERE observation.provider = 'garmin'
|
|
410
|
+
AND observation.source_type = 'activity_summary'
|
|
411
|
+
AND observation.source_remote_id = entity.remote_id
|
|
412
|
+
AND entity.provider = 'garmin'
|
|
413
|
+
AND entity.entity_type = 'activity'
|
|
414
|
+
AND gmt_value IS NOT NULL
|
|
415
|
+
AND NOT regexp_matches(gmt_value, '(Z|[+-][0-9]{2}:?[0-9]{2})$');
|
|
416
|
+
|
|
417
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
418
|
+
`,
|
|
419
|
+
};
|
|
420
|
+
const garminLactateThresholdMetricsMigration = {
|
|
421
|
+
version: 12,
|
|
422
|
+
name: 'garmin_lactate_threshold_training_metrics',
|
|
423
|
+
sql: `
|
|
424
|
+
WITH source AS (
|
|
425
|
+
SELECT remote_id, raw_object_hash,
|
|
426
|
+
try_cast(
|
|
427
|
+
CASE WHEN regexp_matches(coalesce(json_extract_string(payload_json, '$.power.calendarDate'), ''), '(Z|[+-][0-9]{2}:?[0-9]{2})$')
|
|
428
|
+
THEN replace(json_extract_string(payload_json, '$.power.calendarDate'), ' ', 'T')
|
|
429
|
+
ELSE replace(json_extract_string(payload_json, '$.power.calendarDate'), ' ', 'T') || 'Z'
|
|
430
|
+
END AS TIMESTAMPTZ
|
|
431
|
+
) AS power_observed_at,
|
|
432
|
+
try_cast(
|
|
433
|
+
CASE WHEN regexp_matches(coalesce(json_extract_string(payload_json, '$.speed_and_heart_rate.calendarDate'), ''), '(Z|[+-][0-9]{2}:?[0-9]{2})$')
|
|
434
|
+
THEN replace(json_extract_string(payload_json, '$.speed_and_heart_rate.calendarDate'), ' ', 'T')
|
|
435
|
+
ELSE replace(json_extract_string(payload_json, '$.speed_and_heart_rate.calendarDate'), ' ', 'T') || 'Z'
|
|
436
|
+
END AS TIMESTAMPTZ
|
|
437
|
+
) AS heart_rate_observed_at,
|
|
438
|
+
try_cast(json_extract_string(payload_json, '$.power.functionalThresholdPower') AS DOUBLE) AS running_power_w,
|
|
439
|
+
try_cast(json_extract_string(payload_json, '$.power.powerToWeight') AS DOUBLE) AS running_power_w_kg,
|
|
440
|
+
try_cast(json_extract_string(payload_json, '$.speed_and_heart_rate.speed') AS DOUBLE) AS running_pace_encoding,
|
|
441
|
+
try_cast(json_extract_string(payload_json, '$.speed_and_heart_rate.heartRate') AS DOUBLE) AS running_heart_rate_bpm,
|
|
442
|
+
try_cast(json_extract_string(payload_json, '$.speed_and_heart_rate.heartRateCycling') AS DOUBLE) AS cycling_heart_rate_bpm,
|
|
443
|
+
json_extract_string(payload_json, '$.power.origin') AS origin,
|
|
444
|
+
try_cast(json_extract_string(payload_json, '$.power.isStale') AS BOOLEAN) AS is_stale,
|
|
445
|
+
try_cast(json_extract_string(payload_json, '$.power.weight') AS DOUBLE) AS weight_kg,
|
|
446
|
+
json_extract_string(payload_json, '$.power.ftpCreateTime') AS ftp_create_time,
|
|
447
|
+
json_extract_string(payload_json, '$.power.weightCreateTime') AS weight_create_time
|
|
448
|
+
FROM source_entities
|
|
449
|
+
WHERE provider = 'garmin' AND entity_type = 'lactate_threshold'
|
|
450
|
+
), observations AS (
|
|
451
|
+
SELECT 'garmin:lactate_threshold:' || remote_id || ':running_power' AS observation_id,
|
|
452
|
+
'running_lactate_threshold_power_w' AS metric_name, 'running' AS sport,
|
|
453
|
+
coalesce(power_observed_at, heart_rate_observed_at) AS observed_at, running_power_w AS value_number,
|
|
454
|
+
'W' AS unit, json_object('origin', origin, 'isStale', is_stale, 'weightKg', weight_kg,
|
|
455
|
+
'powerToWeight', running_power_w_kg, 'ftpCreateTime', ftp_create_time, 'weightCreateTime', weight_create_time) AS dimensions_json,
|
|
456
|
+
remote_id, raw_object_hash
|
|
457
|
+
FROM source
|
|
458
|
+
UNION ALL
|
|
459
|
+
SELECT 'garmin:lactate_threshold:' || remote_id || ':running_power_to_weight',
|
|
460
|
+
'running_lactate_threshold_power_w_kg', 'running', coalesce(power_observed_at, heart_rate_observed_at), running_power_w_kg,
|
|
461
|
+
'W/kg', json_object('origin', origin, 'isStale', is_stale, 'weightKg', weight_kg,
|
|
462
|
+
'powerToWeight', running_power_w_kg, 'ftpCreateTime', ftp_create_time, 'weightCreateTime', weight_create_time),
|
|
463
|
+
remote_id, raw_object_hash
|
|
464
|
+
FROM source
|
|
465
|
+
UNION ALL
|
|
466
|
+
SELECT 'garmin:lactate_threshold:' || remote_id || ':running_pace',
|
|
467
|
+
'running_lactate_threshold_pace_s_per_km', 'running', coalesce(heart_rate_observed_at, power_observed_at), running_pace_encoding * 600,
|
|
468
|
+
's/km', json_object('rawPaceEncoding', running_pace_encoding), remote_id, raw_object_hash
|
|
469
|
+
FROM source
|
|
470
|
+
UNION ALL
|
|
471
|
+
SELECT 'garmin:lactate_threshold:' || remote_id || ':running_heart_rate',
|
|
472
|
+
'running_lactate_threshold_hr_bpm', 'running', coalesce(heart_rate_observed_at, power_observed_at), running_heart_rate_bpm,
|
|
473
|
+
'bpm', json('{}'), remote_id, raw_object_hash
|
|
474
|
+
FROM source
|
|
475
|
+
UNION ALL
|
|
476
|
+
SELECT 'garmin:lactate_threshold:' || remote_id || ':cycling_heart_rate',
|
|
477
|
+
'cycling_lactate_threshold_hr_bpm', 'cycling', coalesce(heart_rate_observed_at, power_observed_at), cycling_heart_rate_bpm,
|
|
478
|
+
'bpm', json('{}'), remote_id, raw_object_hash
|
|
479
|
+
FROM source
|
|
480
|
+
)
|
|
481
|
+
INSERT INTO training_metric_observations
|
|
482
|
+
(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)
|
|
483
|
+
SELECT observation_id, 'garmin', metric_name, sport, observed_at, value_number, NULL, unit, NULL, dimensions_json,
|
|
484
|
+
'lactate_threshold', remote_id, NULL, raw_object_hash
|
|
485
|
+
FROM observations
|
|
486
|
+
WHERE observed_at IS NOT NULL AND value_number IS NOT NULL
|
|
487
|
+
ON CONFLICT (observation_id) DO UPDATE SET
|
|
488
|
+
sport = excluded.sport, observed_at = excluded.observed_at, value_number = excluded.value_number,
|
|
489
|
+
value_text = excluded.value_text, unit = excluded.unit, device_id = excluded.device_id,
|
|
490
|
+
dimensions_json = excluded.dimensions_json, source_type = excluded.source_type,
|
|
491
|
+
source_remote_id = excluded.source_remote_id, activity_source_id = excluded.activity_source_id,
|
|
492
|
+
raw_object_hash = excluded.raw_object_hash;
|
|
493
|
+
|
|
494
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
495
|
+
`,
|
|
496
|
+
};
|
|
497
|
+
const swimFactsAndQualityMigration = {
|
|
498
|
+
version: 13,
|
|
499
|
+
name: 'swim_facts_quality_and_canonical_provenance',
|
|
500
|
+
sql: `
|
|
501
|
+
-- Explicit length records are intentionally separate from generic samples.
|
|
502
|
+
-- A sample stream without a provider-supplied length boundary must never be
|
|
503
|
+
-- expanded into artificial 25 m (or similar) laps.
|
|
504
|
+
CREATE TABLE IF NOT EXISTS swim_lengths (
|
|
505
|
+
activity_source_id VARCHAR NOT NULL,
|
|
506
|
+
source VARCHAR NOT NULL,
|
|
507
|
+
length_index INTEGER NOT NULL,
|
|
508
|
+
lap_index INTEGER,
|
|
509
|
+
pool_length_m DOUBLE,
|
|
510
|
+
start_time TIMESTAMPTZ,
|
|
511
|
+
duration_s DOUBLE,
|
|
512
|
+
active_duration_s DOUBLE,
|
|
513
|
+
distance_m DOUBLE,
|
|
514
|
+
stroke_count DOUBLE,
|
|
515
|
+
stroke_rate DOUBLE,
|
|
516
|
+
swolf DOUBLE,
|
|
517
|
+
avg_hr DOUBLE,
|
|
518
|
+
max_hr DOUBLE,
|
|
519
|
+
is_rest BOOLEAN,
|
|
520
|
+
confidence VARCHAR NOT NULL,
|
|
521
|
+
metrics_json JSON NOT NULL DEFAULT '{}',
|
|
522
|
+
raw_object_hash VARCHAR,
|
|
523
|
+
PRIMARY KEY (activity_source_id, source, length_index)
|
|
524
|
+
);
|
|
525
|
+
CREATE INDEX IF NOT EXISTS swim_lengths_source_idx ON swim_lengths (activity_source_id, source, length_index);
|
|
526
|
+
|
|
527
|
+
-- Sets preserve the provider's grouping rather than turning a detected
|
|
528
|
+
-- "12 x 52 m" block into a user-authored 12 x 50 m workout.
|
|
529
|
+
CREATE TABLE IF NOT EXISTS swim_sets (
|
|
530
|
+
activity_source_id VARCHAR NOT NULL,
|
|
531
|
+
source_type VARCHAR NOT NULL,
|
|
532
|
+
set_index INTEGER NOT NULL,
|
|
533
|
+
label VARCHAR,
|
|
534
|
+
reps INTEGER,
|
|
535
|
+
rep_distance_m DOUBLE,
|
|
536
|
+
total_distance_m DOUBLE,
|
|
537
|
+
work_s DOUBLE,
|
|
538
|
+
rest_s DOUBLE,
|
|
539
|
+
avg_pace DOUBLE,
|
|
540
|
+
avg_hr DOUBLE,
|
|
541
|
+
max_hr DOUBLE,
|
|
542
|
+
stroke_rate DOUBLE,
|
|
543
|
+
metrics_json JSON NOT NULL DEFAULT '{}',
|
|
544
|
+
raw_object_hash VARCHAR,
|
|
545
|
+
PRIMARY KEY (activity_source_id, source_type, set_index)
|
|
546
|
+
);
|
|
547
|
+
CREATE INDEX IF NOT EXISTS swim_sets_source_idx ON swim_sets (activity_source_id, source_type, set_index);
|
|
548
|
+
|
|
549
|
+
CREATE TABLE IF NOT EXISTS activity_quality_flags (
|
|
550
|
+
activity_source_id VARCHAR NOT NULL,
|
|
551
|
+
flag_code VARCHAR NOT NULL,
|
|
552
|
+
severity VARCHAR NOT NULL,
|
|
553
|
+
details_json JSON NOT NULL DEFAULT '{}',
|
|
554
|
+
raw_object_hash VARCHAR,
|
|
555
|
+
PRIMARY KEY (activity_source_id, flag_code)
|
|
556
|
+
);
|
|
557
|
+
CREATE INDEX IF NOT EXISTS activity_quality_flags_source_idx ON activity_quality_flags (activity_source_id, severity);
|
|
558
|
+
|
|
559
|
+
ALTER TABLE activity_intervals ADD COLUMN IF NOT EXISTS duration_s DOUBLE;
|
|
560
|
+
ALTER TABLE activity_intervals ADD COLUMN IF NOT EXISTS moving_s DOUBLE;
|
|
561
|
+
ALTER TABLE activity_intervals ADD COLUMN IF NOT EXISTS source_type VARCHAR;
|
|
562
|
+
-- DuckDB binds SELECT intervals.* at view-creation time, so refresh this
|
|
563
|
+
-- projection after extending the underlying interval table.
|
|
564
|
+
CREATE OR REPLACE VIEW activity_interval_facts AS
|
|
565
|
+
SELECT intervals.*, source.activity_id, source.provider,
|
|
566
|
+
activity.started_at_utc, activity.sport, activity.name
|
|
567
|
+
FROM activity_intervals AS intervals
|
|
568
|
+
JOIN activity_sources AS source USING (activity_source_id)
|
|
569
|
+
JOIN activities AS activity USING (activity_id);
|
|
570
|
+
|
|
571
|
+
-- Older Garmin imports retained the useful split summaries in one raw
|
|
572
|
+
-- payload, then tried to read interval fields from its wrapper object. A
|
|
573
|
+
-- row per provider summary restores the supplied duration/HR/distance
|
|
574
|
+
-- without manufacturing chronology or per-length splits.
|
|
575
|
+
DELETE FROM activity_intervals AS interval
|
|
576
|
+
USING activity_sources AS source, source_entities AS entity
|
|
577
|
+
WHERE interval.activity_source_id = source.activity_source_id
|
|
578
|
+
AND source.provider = 'garmin'
|
|
579
|
+
AND entity.provider = 'garmin'
|
|
580
|
+
AND entity.entity_type = 'activity_interval'
|
|
581
|
+
AND entity.parent_remote_id = source.remote_activity_id
|
|
582
|
+
AND interval.interval_key = entity.remote_id
|
|
583
|
+
AND json_type(entity.payload_json, '$.splitSummaries') = 'ARRAY';
|
|
584
|
+
-- Some Garmin detail endpoints supplied a wrapper with no interval fields
|
|
585
|
+
-- at all. Those rows are neither a split nor a useful no-distance segment;
|
|
586
|
+
-- remove only that entirely-null legacy shape before adding the supplied
|
|
587
|
+
-- split summaries below.
|
|
588
|
+
DELETE FROM activity_intervals AS interval
|
|
589
|
+
USING activity_sources AS source
|
|
590
|
+
WHERE interval.activity_source_id = source.activity_source_id
|
|
591
|
+
AND source.provider = 'garmin'
|
|
592
|
+
AND interval.source_type IS NULL
|
|
593
|
+
AND interval.start_s IS NULL AND interval.end_s IS NULL AND interval.duration_s IS NULL AND interval.moving_s IS NULL
|
|
594
|
+
AND interval.distance_m IS NULL AND interval.avg_power IS NULL AND interval.avg_hr IS NULL
|
|
595
|
+
AND interval.avg_pace IS NULL AND interval.intensity IS NULL;
|
|
596
|
+
|
|
597
|
+
INSERT INTO activity_intervals
|
|
598
|
+
(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)
|
|
599
|
+
SELECT source.activity_source_id,
|
|
600
|
+
'garmin:split_summary:' || entry.key,
|
|
601
|
+
json_extract_string(entry.value, '$.splitType'),
|
|
602
|
+
NULL, NULL,
|
|
603
|
+
try_cast(json_extract_string(entry.value, '$.distance') AS DOUBLE),
|
|
604
|
+
NULL,
|
|
605
|
+
try_cast(json_extract_string(entry.value, '$.averageHR') AS DOUBLE),
|
|
606
|
+
NULL, NULL,
|
|
607
|
+
json_object('splitSummary', entry.value),
|
|
608
|
+
try_cast(json_extract_string(entry.value, '$.duration') AS DOUBLE),
|
|
609
|
+
try_cast(json_extract_string(entry.value, '$.movingDuration') AS DOUBLE),
|
|
610
|
+
'garmin_detected'
|
|
611
|
+
FROM source_entities AS entity
|
|
612
|
+
JOIN activity_sources AS source
|
|
613
|
+
ON source.provider = 'garmin' AND source.remote_activity_id = entity.parent_remote_id
|
|
614
|
+
CROSS JOIN json_each(entity.payload_json, '$.splitSummaries') AS entry
|
|
615
|
+
WHERE entity.provider = 'garmin' AND entity.entity_type = 'activity_interval'
|
|
616
|
+
ON CONFLICT (activity_source_id, interval_key) DO UPDATE SET
|
|
617
|
+
label = excluded.label, start_s = excluded.start_s, end_s = excluded.end_s,
|
|
618
|
+
distance_m = excluded.distance_m, avg_power = excluded.avg_power, avg_hr = excluded.avg_hr,
|
|
619
|
+
avg_pace = excluded.avg_pace, intensity = excluded.intensity, metrics_json = excluded.metrics_json,
|
|
620
|
+
duration_s = excluded.duration_s, moving_s = excluded.moving_s, source_type = excluded.source_type;
|
|
621
|
+
|
|
622
|
+
INSERT INTO swim_sets
|
|
623
|
+
(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)
|
|
624
|
+
SELECT source.activity_source_id, 'garmin_detected', try_cast(entry.key AS INTEGER),
|
|
625
|
+
json_extract_string(entry.value, '$.splitType'),
|
|
626
|
+
try_cast(json_extract_string(entry.value, '$.noOfSplits') AS INTEGER),
|
|
627
|
+
NULL,
|
|
628
|
+
try_cast(json_extract_string(entry.value, '$.distance') AS DOUBLE),
|
|
629
|
+
try_cast(json_extract_string(entry.value, '$.movingDuration') AS DOUBLE),
|
|
630
|
+
NULL, NULL,
|
|
631
|
+
try_cast(json_extract_string(entry.value, '$.averageHR') AS DOUBLE),
|
|
632
|
+
try_cast(json_extract_string(entry.value, '$.maxHR') AS DOUBLE),
|
|
633
|
+
NULL,
|
|
634
|
+
json_object('splitSummary', entry.value), entity.raw_object_hash
|
|
635
|
+
FROM source_entities AS entity
|
|
636
|
+
JOIN activity_sources AS source
|
|
637
|
+
ON source.provider = 'garmin' AND source.remote_activity_id = entity.parent_remote_id
|
|
638
|
+
CROSS JOIN json_each(entity.payload_json, '$.splitSummaries') AS entry
|
|
639
|
+
WHERE entity.provider = 'garmin' AND entity.entity_type = 'activity_interval'
|
|
640
|
+
ON CONFLICT (activity_source_id, source_type, set_index) DO UPDATE SET
|
|
641
|
+
label = excluded.label, reps = excluded.reps, rep_distance_m = excluded.rep_distance_m,
|
|
642
|
+
total_distance_m = excluded.total_distance_m, work_s = excluded.work_s, rest_s = excluded.rest_s,
|
|
643
|
+
avg_pace = excluded.avg_pace, avg_hr = excluded.avg_hr, max_hr = excluded.max_hr,
|
|
644
|
+
stroke_rate = excluded.stroke_rate, metrics_json = excluded.metrics_json,
|
|
645
|
+
raw_object_hash = excluded.raw_object_hash;
|
|
646
|
+
|
|
647
|
+
-- Intervals.icu exposes its swim blocks on the activity summary. Parse
|
|
648
|
+
-- only its documented compact labels; duration and pace stay null because
|
|
649
|
+
-- that payload does not supply them reliably.
|
|
650
|
+
WITH blocks AS (
|
|
651
|
+
SELECT source.activity_source_id, source.raw_object_hash, try_cast(entry.key AS INTEGER) AS set_index,
|
|
652
|
+
json_extract_string(entry.value, '$') AS raw_label,
|
|
653
|
+
regexp_extract(json_extract_string(entry.value, '$'), '^\\s*(\\d+)\\s*x\\s*(\\d+(?:\\.\\d+)?)\\s*m(?:\\s+(\\d+(?:\\.\\d+)?)\\s*bpm)?\\s*$', 1) AS reps_text,
|
|
654
|
+
regexp_extract(json_extract_string(entry.value, '$'), '^\\s*(\\d+)\\s*x\\s*(\\d+(?:\\.\\d+)?)\\s*m(?:\\s+(\\d+(?:\\.\\d+)?)\\s*bpm)?\\s*$', 2) AS distance_text,
|
|
655
|
+
regexp_extract(json_extract_string(entry.value, '$'), '^\\s*(\\d+)\\s*x\\s*(\\d+(?:\\.\\d+)?)\\s*m(?:\\s+(\\d+(?:\\.\\d+)?)\\s*bpm)?\\s*$', 3) AS hr_text
|
|
656
|
+
FROM source_entities AS entity
|
|
657
|
+
JOIN activity_sources AS source
|
|
658
|
+
ON source.provider = 'intervals' AND source.remote_activity_id = entity.remote_id
|
|
659
|
+
CROSS JOIN json_each(entity.payload_json, '$.interval_summary') AS entry
|
|
660
|
+
WHERE entity.provider = 'intervals' AND entity.entity_type = 'activity'
|
|
661
|
+
)
|
|
662
|
+
INSERT INTO swim_sets
|
|
663
|
+
(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)
|
|
664
|
+
SELECT activity_source_id, 'intervals_auto', set_index, raw_label,
|
|
665
|
+
try_cast(reps_text AS INTEGER), try_cast(distance_text AS DOUBLE),
|
|
666
|
+
try_cast(reps_text AS DOUBLE) * try_cast(distance_text AS DOUBLE),
|
|
667
|
+
NULL, NULL, NULL, try_cast(nullif(hr_text, '') AS DOUBLE), NULL, NULL,
|
|
668
|
+
json_object('rawLabel', raw_label, 'providerGrouping', 'intervals_auto'), raw_object_hash
|
|
669
|
+
FROM blocks
|
|
670
|
+
WHERE reps_text <> '' AND distance_text <> ''
|
|
671
|
+
ON CONFLICT (activity_source_id, source_type, set_index) DO UPDATE SET
|
|
672
|
+
label = excluded.label, reps = excluded.reps, rep_distance_m = excluded.rep_distance_m,
|
|
673
|
+
total_distance_m = excluded.total_distance_m, work_s = excluded.work_s, rest_s = excluded.rest_s,
|
|
674
|
+
avg_pace = excluded.avg_pace, avg_hr = excluded.avg_hr, max_hr = excluded.max_hr,
|
|
675
|
+
stroke_rate = excluded.stroke_rate, metrics_json = excluded.metrics_json,
|
|
676
|
+
raw_object_hash = excluded.raw_object_hash;
|
|
677
|
+
|
|
678
|
+
INSERT INTO activity_intervals
|
|
679
|
+
(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)
|
|
680
|
+
SELECT activity_source_id, 'intervals:auto:' || set_index, label,
|
|
681
|
+
NULL, NULL, total_distance_m, NULL, avg_hr, NULL, NULL,
|
|
682
|
+
metrics_json, NULL, NULL, 'intervals_auto'
|
|
683
|
+
FROM swim_sets
|
|
684
|
+
WHERE source_type = 'intervals_auto'
|
|
685
|
+
ON CONFLICT (activity_source_id, interval_key) DO UPDATE SET
|
|
686
|
+
label = excluded.label, start_s = excluded.start_s, end_s = excluded.end_s,
|
|
687
|
+
distance_m = excluded.distance_m, avg_power = excluded.avg_power, avg_hr = excluded.avg_hr,
|
|
688
|
+
avg_pace = excluded.avg_pace, intensity = excluded.intensity, metrics_json = excluded.metrics_json,
|
|
689
|
+
duration_s = excluded.duration_s, moving_s = excluded.moving_s, source_type = excluded.source_type;
|
|
690
|
+
|
|
691
|
+
-- Quality flags are source facts. They make the data limitation visible
|
|
692
|
+
-- without suppressing the original provider summary.
|
|
693
|
+
INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
|
|
694
|
+
SELECT source.activity_source_id, 'pool_length_implausible', 'warning',
|
|
695
|
+
json_object('poolLengthM', CASE WHEN pool_length >= 100 THEN pool_length / 100 ELSE pool_length END), source.raw_object_hash
|
|
696
|
+
FROM activity_sources AS source
|
|
697
|
+
JOIN activities AS activity USING (activity_id)
|
|
698
|
+
JOIN activity_summaries AS summary USING (activity_source_id)
|
|
699
|
+
CROSS JOIN LATERAL (SELECT try_cast(json_extract_string(summary.metrics_json, '$.poolLength') AS DOUBLE) AS pool_length) AS values
|
|
700
|
+
WHERE source.provider = 'garmin' AND lower(activity.sport) = 'lap_swimming'
|
|
701
|
+
AND pool_length IS NOT NULL
|
|
702
|
+
AND (CASE WHEN pool_length >= 100 THEN pool_length / 100 ELSE pool_length END) NOT BETWEEN 15 AND 100
|
|
703
|
+
ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash;
|
|
704
|
+
|
|
705
|
+
INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
|
|
706
|
+
SELECT source.activity_source_id, 'zero_swim_speed_and_cadence', 'warning',
|
|
707
|
+
json_object('averageSpeedMps', average_speed, 'strokeCadenceSpm', cadence, 'durationS', greatest(coalesce(summary.moving_s, 0), coalesce(summary.elapsed_s, 0))), source.raw_object_hash
|
|
708
|
+
FROM activity_sources AS source
|
|
709
|
+
JOIN activities AS activity USING (activity_id)
|
|
710
|
+
JOIN activity_summaries AS summary USING (activity_source_id)
|
|
711
|
+
CROSS JOIN LATERAL (
|
|
712
|
+
SELECT try_cast(json_extract_string(summary.metrics_json, '$.averageSpeed') AS DOUBLE) AS average_speed,
|
|
713
|
+
try_cast(json_extract_string(summary.metrics_json, '$.averageSwimCadenceInStrokesPerMinute') AS DOUBLE) AS cadence
|
|
714
|
+
) AS values
|
|
715
|
+
WHERE source.provider = 'garmin' AND lower(activity.sport) = 'lap_swimming'
|
|
716
|
+
AND greatest(coalesce(summary.moving_s, 0), coalesce(summary.elapsed_s, 0)) >= 900
|
|
717
|
+
AND average_speed = 0 AND cadence = 0
|
|
718
|
+
ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash;
|
|
719
|
+
|
|
720
|
+
INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
|
|
721
|
+
SELECT source.activity_source_id, 'active_lengths_distance_mismatch', 'warning',
|
|
722
|
+
json_object('activeLengths', active_lengths, 'poolLengthM', pool_length_m, 'expectedDistanceM', active_lengths * pool_length_m, 'summaryDistanceM', summary.distance_m), source.raw_object_hash
|
|
723
|
+
FROM activity_sources AS source
|
|
724
|
+
JOIN activities AS activity USING (activity_id)
|
|
725
|
+
JOIN activity_summaries AS summary USING (activity_source_id)
|
|
726
|
+
CROSS JOIN LATERAL (
|
|
727
|
+
SELECT try_cast(json_extract_string(summary.metrics_json, '$.activeLengths') AS DOUBLE) AS active_lengths,
|
|
728
|
+
try_cast(json_extract_string(summary.metrics_json, '$.poolLength') AS DOUBLE) AS raw_pool_length
|
|
729
|
+
) AS raw_values
|
|
730
|
+
CROSS JOIN LATERAL (SELECT CASE WHEN raw_pool_length >= 100 THEN raw_pool_length / 100 ELSE raw_pool_length END AS pool_length_m) AS values
|
|
731
|
+
WHERE source.provider = 'garmin' AND lower(activity.sport) = 'lap_swimming'
|
|
732
|
+
AND active_lengths > 0 AND pool_length_m > 0 AND summary.distance_m > 0
|
|
733
|
+
AND abs(active_lengths * pool_length_m - summary.distance_m) > greatest(pool_length_m, active_lengths * pool_length_m * 0.02)
|
|
734
|
+
ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash;
|
|
735
|
+
|
|
736
|
+
INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
|
|
737
|
+
SELECT source.activity_source_id, 'swim_length_data_unavailable', 'info',
|
|
738
|
+
json_object('reason', 'No explicit provider-supplied per-length records were imported.'), source.raw_object_hash
|
|
739
|
+
FROM activity_sources AS source
|
|
740
|
+
JOIN activities AS activity USING (activity_id)
|
|
741
|
+
WHERE source.provider = 'garmin' AND lower(activity.sport) = 'lap_swimming'
|
|
742
|
+
AND NOT EXISTS (SELECT 1 FROM swim_lengths AS length WHERE length.activity_source_id = source.activity_source_id)
|
|
743
|
+
ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash;
|
|
744
|
+
|
|
745
|
+
WITH comparisons AS (
|
|
746
|
+
SELECT activity_id,
|
|
747
|
+
max(activity_source_id) FILTER (WHERE provider = 'garmin') AS garmin_source_id,
|
|
748
|
+
max(distance_m) FILTER (WHERE provider = 'garmin') AS garmin_distance_m,
|
|
749
|
+
max(activity_source_id) FILTER (WHERE provider = 'intervals') AS intervals_source_id,
|
|
750
|
+
max(distance_m) FILTER (WHERE provider = 'intervals') AS intervals_distance_m
|
|
751
|
+
FROM activity_summary_facts
|
|
752
|
+
GROUP BY activity_id
|
|
753
|
+
)
|
|
754
|
+
INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
|
|
755
|
+
SELECT source.activity_source_id, 'provider_distance_disagreement', 'warning',
|
|
756
|
+
json_object('garminDistanceM', comparison.garmin_distance_m, 'intervalsDistanceM', comparison.intervals_distance_m,
|
|
757
|
+
'differenceM', abs(comparison.garmin_distance_m - comparison.intervals_distance_m), 'toleranceM', greatest(50, comparison.garmin_distance_m * 0.05)),
|
|
758
|
+
source.raw_object_hash
|
|
759
|
+
FROM comparisons AS comparison
|
|
760
|
+
JOIN activity_sources AS source ON source.activity_id = comparison.activity_id AND source.provider IN ('garmin', 'intervals')
|
|
761
|
+
WHERE comparison.garmin_distance_m IS NOT NULL AND comparison.intervals_distance_m IS NOT NULL
|
|
762
|
+
AND abs(comparison.garmin_distance_m - comparison.intervals_distance_m) > greatest(50, comparison.garmin_distance_m * 0.05)
|
|
763
|
+
ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash;
|
|
764
|
+
|
|
765
|
+
CREATE OR REPLACE VIEW swim_length_facts AS
|
|
766
|
+
SELECT length.*, source.activity_id, source.provider, activity.started_at_utc, activity.sport, activity.name
|
|
767
|
+
FROM swim_lengths AS length
|
|
768
|
+
JOIN activity_sources AS source USING (activity_source_id)
|
|
769
|
+
JOIN activities AS activity USING (activity_id);
|
|
770
|
+
|
|
771
|
+
CREATE OR REPLACE VIEW swim_set_facts AS
|
|
772
|
+
SELECT swim_set.*, source.activity_id, source.provider, activity.started_at_utc, activity.sport, activity.name
|
|
773
|
+
FROM swim_sets AS swim_set
|
|
774
|
+
JOIN activity_sources AS source USING (activity_source_id)
|
|
775
|
+
JOIN activities AS activity USING (activity_id);
|
|
776
|
+
|
|
777
|
+
CREATE OR REPLACE VIEW activity_quality_flag_facts AS
|
|
778
|
+
SELECT flag.*, source.activity_id, source.provider, activity.started_at_utc, activity.sport, activity.name
|
|
779
|
+
FROM activity_quality_flags AS flag
|
|
780
|
+
JOIN activity_sources AS source USING (activity_source_id)
|
|
781
|
+
JOIN activities AS activity USING (activity_id);
|
|
782
|
+
|
|
783
|
+
CREATE OR REPLACE VIEW canonical_activity_facts AS
|
|
784
|
+
WITH source_metrics AS (
|
|
785
|
+
SELECT source.activity_id, source.activity_source_id, source.provider,
|
|
786
|
+
summary.distance_m, summary.moving_s, summary.elapsed_s, summary.avg_hr
|
|
787
|
+
FROM activity_sources AS source
|
|
788
|
+
LEFT JOIN activity_summaries AS summary USING (activity_source_id)
|
|
789
|
+
), per_activity AS (
|
|
790
|
+
SELECT activity_id,
|
|
791
|
+
max(activity_source_id) FILTER (WHERE provider = 'garmin') AS garmin_activity_source_id,
|
|
792
|
+
max(distance_m) FILTER (WHERE provider = 'garmin') AS garmin_distance_m,
|
|
793
|
+
max(moving_s) FILTER (WHERE provider = 'garmin') AS garmin_moving_s,
|
|
794
|
+
max(elapsed_s) FILTER (WHERE provider = 'garmin') AS garmin_elapsed_s,
|
|
795
|
+
max(avg_hr) FILTER (WHERE provider = 'garmin') AS garmin_avg_hr,
|
|
796
|
+
max(activity_source_id) FILTER (WHERE provider = 'intervals') AS intervals_activity_source_id,
|
|
797
|
+
max(distance_m) FILTER (WHERE provider = 'intervals') AS intervals_distance_m,
|
|
798
|
+
max(moving_s) FILTER (WHERE provider = 'intervals') AS intervals_moving_s,
|
|
799
|
+
max(elapsed_s) FILTER (WHERE provider = 'intervals') AS intervals_elapsed_s,
|
|
800
|
+
max(avg_hr) FILTER (WHERE provider = 'intervals') AS intervals_avg_hr
|
|
801
|
+
FROM source_metrics GROUP BY activity_id
|
|
802
|
+
), flag_rollup AS (
|
|
803
|
+
SELECT source.activity_id,
|
|
804
|
+
json_group_array(json_object('activitySourceId', flag.activity_source_id, 'code', flag.flag_code,
|
|
805
|
+
'severity', flag.severity, 'details', flag.details_json)) AS quality_flags
|
|
806
|
+
FROM activity_quality_flags AS flag
|
|
807
|
+
JOIN activity_sources AS source USING (activity_source_id)
|
|
808
|
+
GROUP BY source.activity_id
|
|
809
|
+
)
|
|
810
|
+
SELECT activity.activity_id, activity.started_at_utc, activity.started_at_local, activity.timezone, activity.sport, activity.name, activity.link_state,
|
|
811
|
+
per_activity.garmin_activity_source_id, per_activity.garmin_distance_m, per_activity.garmin_moving_s, per_activity.garmin_elapsed_s, per_activity.garmin_avg_hr,
|
|
812
|
+
per_activity.intervals_activity_source_id, per_activity.intervals_distance_m, per_activity.intervals_moving_s, per_activity.intervals_elapsed_s, per_activity.intervals_avg_hr,
|
|
813
|
+
coalesce(per_activity.garmin_distance_m, per_activity.intervals_distance_m) AS resolved_distance_m,
|
|
814
|
+
CASE WHEN per_activity.garmin_distance_m IS NOT NULL THEN 'garmin' WHEN per_activity.intervals_distance_m IS NOT NULL THEN 'intervals' ELSE NULL END AS distance_source,
|
|
815
|
+
coalesce(per_activity.garmin_moving_s, per_activity.intervals_moving_s) AS resolved_moving_s,
|
|
816
|
+
CASE WHEN per_activity.garmin_moving_s IS NOT NULL THEN 'garmin' WHEN per_activity.intervals_moving_s IS NOT NULL THEN 'intervals' ELSE NULL END AS moving_s_source,
|
|
817
|
+
coalesce(per_activity.garmin_elapsed_s, per_activity.intervals_elapsed_s) AS resolved_elapsed_s,
|
|
818
|
+
CASE WHEN per_activity.garmin_elapsed_s IS NOT NULL THEN 'garmin' WHEN per_activity.intervals_elapsed_s IS NOT NULL THEN 'intervals' ELSE NULL END AS elapsed_s_source,
|
|
819
|
+
coalesce(per_activity.garmin_avg_hr, per_activity.intervals_avg_hr) AS resolved_avg_hr,
|
|
820
|
+
CASE WHEN per_activity.garmin_avg_hr IS NOT NULL THEN 'garmin' WHEN per_activity.intervals_avg_hr IS NOT NULL THEN 'intervals' ELSE NULL END AS avg_hr_source,
|
|
821
|
+
CASE WHEN per_activity.garmin_distance_m IS NOT NULL AND per_activity.intervals_distance_m IS NOT NULL
|
|
822
|
+
THEN abs(per_activity.garmin_distance_m - per_activity.intervals_distance_m) ELSE NULL END AS provider_distance_difference_m,
|
|
823
|
+
coalesce(flag_rollup.quality_flags, cast('[]' AS JSON)) AS quality_flags
|
|
824
|
+
FROM activities AS activity
|
|
825
|
+
LEFT JOIN per_activity USING (activity_id)
|
|
826
|
+
LEFT JOIN flag_rollup USING (activity_id);
|
|
827
|
+
|
|
828
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
829
|
+
`,
|
|
830
|
+
};
|
|
831
|
+
export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration];
|
|
832
|
+
//# sourceMappingURL=migrations.js.map
|