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,450 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, timedelta
4
+ from pathlib import Path
5
+ import hashlib
6
+ import json
7
+ from typing import Any, Callable, Mapping
8
+
9
+ from .registry import GARMIN_READ_METHODS, assert_read_only_registry
10
+ from .staging import StagingWriter
11
+ from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, write_parquet
12
+
13
+
14
+ def as_dict(value: Any) -> dict[str, Any]:
15
+ return value if isinstance(value, dict) else {"value": value}
16
+
17
+
18
+ def as_items(value: Any) -> list[dict[str, Any]]:
19
+ if isinstance(value, list):
20
+ return [as_dict(item) for item in value]
21
+ if isinstance(value, dict):
22
+ for key in ("activityList", "items", "data", "meals", "foodLogEntries", "hrvSummaries", "individualStats", "results", "values", "events", "racePredictions", "predictions", "dailyPredictions"):
23
+ if isinstance(value.get(key), list):
24
+ return [as_dict(item) for item in value[key]]
25
+ return [value]
26
+ return []
27
+
28
+
29
+ def functional_threshold_power_items(value: Any) -> list[dict[str, Any]]:
30
+ """Extract records from the undocumented threshold-power range response."""
31
+ if isinstance(value, list):
32
+ return [as_dict(item) for item in value]
33
+ if isinstance(value, dict):
34
+ for key in ("items", "data", "results", "stats", "series", "values"):
35
+ nested = value.get(key)
36
+ if isinstance(nested, list):
37
+ return [as_dict(item) for item in nested]
38
+ return [value]
39
+ return []
40
+
41
+
42
+ def ftp_history_date(payload: Mapping[str, Any]) -> str | None:
43
+ for key in ("until", "calendarDate", "date", "updatedDate", "from"):
44
+ value = payload.get(key)
45
+ if not isinstance(value, str) or len(value) < 10:
46
+ continue
47
+ try:
48
+ return date.fromisoformat(value[:10]).isoformat()
49
+ except ValueError:
50
+ continue
51
+ return None
52
+
53
+
54
+ def remote_id(payload: dict[str, Any], fallback: str) -> str:
55
+ for key in ("activityId", "workoutId", "scheduleId", "id", "calendarDate", "date", "uuid"):
56
+ if payload.get(key) is not None:
57
+ return str(payload[key])
58
+ return fallback
59
+
60
+
61
+ def payload_date(payload: Mapping[str, Any]) -> str | None:
62
+ """Return the first ISO calendar day exposed by a Garmin payload."""
63
+ for key in ("calendarDate", "date", "until", "from", "timestamp", "timestampLocal", "startTimestampGMT"):
64
+ value = payload.get(key)
65
+ if not isinstance(value, str) or len(value) < 10:
66
+ continue
67
+ try:
68
+ return date.fromisoformat(value[:10]).isoformat()
69
+ except ValueError:
70
+ continue
71
+ for key in ("generic", "cycling", "heatAltitudeAcclimation", "hrvSummary"):
72
+ nested = payload.get(key)
73
+ if isinstance(nested, Mapping):
74
+ nested_date = payload_date(nested)
75
+ if nested_date:
76
+ return nested_date
77
+ return None
78
+
79
+
80
+ class GarminStagingWorker:
81
+ """Extract Garmin data through the explicit read-only registry into JSONL/Parquet staging."""
82
+
83
+ def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None) -> None:
84
+ self.api = api
85
+ self.writer = writer
86
+ self.data_dir = data_dir
87
+ self.known_activity_hashes = dict(known_activity_hashes or {})
88
+
89
+ def sync(
90
+ self,
91
+ daily_from_date: date | None,
92
+ activity_from_date: date | None,
93
+ to_date: date | None = None,
94
+ daily_to_date: date | None = None,
95
+ activity_to_date: date | None = None,
96
+ include_non_historical: bool = True,
97
+ ) -> None:
98
+ assert_read_only_registry()
99
+ end = to_date or date.today()
100
+ daily_end = daily_to_date or end
101
+ activity_end = activity_to_date or end
102
+ if include_non_historical:
103
+ self._singletons()
104
+ if daily_from_date and daily_from_date <= daily_end:
105
+ self._daily(daily_from_date, daily_end)
106
+ self._range(daily_from_date, daily_end)
107
+ self._cycling_ftp_history(daily_from_date, daily_end)
108
+ self._max_metrics_history(daily_from_date, daily_end)
109
+ self._hrv_history(daily_from_date, daily_end)
110
+ self._score_history(daily_from_date, daily_end)
111
+ if activity_from_date and activity_from_date <= activity_end:
112
+ self._activities(activity_from_date, activity_end)
113
+ if include_non_historical:
114
+ self._collections()
115
+
116
+ def _capture(self, endpoint: str, action: Callable[[], Any], remote_id_value: str | None = None, scope: dict[str, Any] | None = None) -> tuple[Any | None, str | None]:
117
+ try:
118
+ payload = action()
119
+ raw_hash = self.writer.archive_json(endpoint, remote_id_value, payload, scope)
120
+ return payload, raw_hash
121
+ except Exception as error: # Provider availability is intentionally isolated per operation.
122
+ self.writer.error(endpoint, str(error), remote_id_value)
123
+ return None, None
124
+
125
+ def _entity(
126
+ self,
127
+ endpoint: str,
128
+ entity_type: str,
129
+ payload: Any,
130
+ raw_hash: str | None,
131
+ occurred_on: str | None = None,
132
+ parent_remote_id: str | None = None,
133
+ fallback_scope: str | None = None,
134
+ ) -> None:
135
+ """Stage source entities without allowing date-scoped calls to collide."""
136
+ for index, item in enumerate(as_items(payload)):
137
+ item_day = occurred_on or payload_date(item)
138
+ if occurred_on:
139
+ discriminator = str(item.get("id") or item.get("uuid") or item.get("timestamp") or item.get("inputContext") or index)
140
+ item_id = f"{endpoint}:{occurred_on}:{discriminator}"
141
+ else:
142
+ item_id = remote_id(item, f"{endpoint}:{fallback_scope or item_day or 'undated'}:{index}")
143
+ self.writer.source_entity(entity_type, item_id, item, raw_hash, parent_remote_id, item_day)
144
+
145
+ def _singletons(self) -> None:
146
+ calls = {
147
+ "user_profile": ("get_user_profile", "profile"),
148
+ "userprofile_settings": ("get_userprofile_settings", "profile_setting"),
149
+ "devices": ("get_devices", "device"),
150
+ "device_last_used": ("get_device_last_used", "device"),
151
+ "device_alarms": ("get_device_alarms", "device_alarm"),
152
+ "lactate_threshold": ("get_lactate_threshold", "lactate_threshold"),
153
+ "heart_rate_zones": ("get_heart_rate_zones", "activity_zone"),
154
+ "power_zones": ("get_power_zones", "activity_zone"),
155
+ "cycling_ftp": ("get_cycling_ftp", "training_metric"),
156
+ "personal_record": ("get_personal_record", "personal_record"),
157
+ "earned_badges": ("get_earned_badges", "badge"),
158
+ "available_badges": ("get_available_badges", "badge"),
159
+ "in_progress_badges": ("get_in_progress_badges", "badge"),
160
+ "training_plans": ("get_training_plans", "training_plan"),
161
+ "pregnancy_summary": ("get_pregnancy_summary", "pregnancy_summary"),
162
+ "golf_user_stats": ("get_golf_user_stats", "golf_user_stat"),
163
+ "golf_club_stats": ("get_golf_club_stats", "golf_club_stat"),
164
+ }
165
+ for endpoint, (method, entity_type) in calls.items():
166
+ payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)())
167
+ if payload is not None:
168
+ self._entity(endpoint, entity_type, payload, raw_hash)
169
+
170
+ def _daily(self, from_date: date, to_date: date) -> None:
171
+ calls = {
172
+ "stats": ("get_stats", "daily_health"), "user_summary": ("get_user_summary", "daily_health"),
173
+ "floors": ("get_floors", "daily_health"), "intensity_minutes": ("get_intensity_minutes_data", "daily_health"),
174
+ "steps": ("get_steps_data", "daily_health"), "heart_rates": ("get_heart_rates", "daily_health"),
175
+ "sleep": ("get_sleep_data", "daily_health"), "stress": ("get_stress_data", "daily_health"),
176
+ "all_day_stress": ("get_all_day_stress", "daily_health"), "body_battery_events": ("get_body_battery_events", "daily_health"),
177
+ "respiration": ("get_respiration_data", "daily_health"), "spo2": ("get_spo2_data", "daily_health"),
178
+ "all_day_events": ("get_all_day_events", "health_event"),
179
+ "training_readiness": ("get_training_readiness", "daily_health"),
180
+ "morning_training_readiness": ("get_morning_training_readiness", "daily_health"), "training_status": ("get_training_status", "training_status"),
181
+ "fitness_age": ("get_fitnessage_data", "fitness_age"), "endurance_score": ("get_endurance_score", "endurance_score"),
182
+ "hydration": ("get_hydration_data", "nutrition_log"), "menstrual": ("get_menstrual_data_for_date", "menstrual"),
183
+ "nutrition_food_log": ("get_nutrition_daily_food_log", "nutrition_log"), "nutrition_meals": ("get_nutrition_daily_meals", "nutrition_log"),
184
+ "nutrition_settings": ("get_nutrition_daily_settings", "nutrition_setting"),
185
+ }
186
+ current = from_date
187
+ while current <= to_date:
188
+ day = current.isoformat()
189
+ for endpoint, (method, entity_type) in calls.items():
190
+ payload, raw_hash = self._capture(endpoint, lambda method=method, day=day: getattr(self.api, method)(day), day, {"date": day})
191
+ if payload is not None:
192
+ self._entity(endpoint, entity_type, payload, raw_hash, day)
193
+ current += timedelta(days=1)
194
+
195
+ def _range(self, from_date: date, to_date: date) -> None:
196
+ calls = {
197
+ "daily_steps": ("get_daily_steps", "daily_health"), "body_battery": ("get_body_battery", "daily_health"),
198
+ "blood_pressure": ("get_blood_pressure", "blood_pressure"), "weigh_ins": ("get_weigh_ins", "body_composition"),
199
+ "progress_summary": ("get_progress_summary_between_dates", "progress_summary"), "menstrual_calendar": ("get_menstrual_calendar_data", "menstrual"),
200
+ }
201
+ for endpoint, (method, entity_type) in calls.items():
202
+ # Garmin rejects broad body-battery and menstrual-calendar ranges.
203
+ # Keep every request inside the provider's 92-day limit.
204
+ window_start = from_date
205
+ # Garmin rejects the Body Battery endpoint's larger historical
206
+ # ranges. Keep it deliberately small; the captures are
207
+ # content-addressed so overlapping retry windows are harmless.
208
+ window_days = 7 if endpoint == "body_battery" else (90 if endpoint == "menstrual_calendar" else (to_date - from_date).days + 1)
209
+ while window_start <= to_date:
210
+ window_end = min(to_date, window_start + timedelta(days=window_days - 1))
211
+ start, end = window_start.isoformat(), window_end.isoformat()
212
+ payload, raw_hash = self._capture(endpoint, lambda method=method, start=start, end=end: getattr(self.api, method)(start, end), None, {"from": start, "to": end})
213
+ if payload is not None:
214
+ self._entity(endpoint, entity_type, payload, raw_hash, fallback_scope=start)
215
+ window_start = window_end + timedelta(days=1)
216
+
217
+ def _cycling_ftp_history(self, from_date: date, to_date: date) -> None:
218
+ """Stage daily cycling FTP setting history from Garmin's range endpoint."""
219
+ endpoint = "functional_threshold_power_range"
220
+ window_start = from_date
221
+ while window_start <= to_date:
222
+ # This private Connect endpoint is not documented with a maximum
223
+ # range. Keep requests aligned with other conservative range calls.
224
+ window_end = min(to_date, window_start + timedelta(days=89))
225
+ start, end = window_start.isoformat(), window_end.isoformat()
226
+ payload, raw_hash = self._capture(
227
+ endpoint,
228
+ lambda start=start, end=end: self.api.get_functional_threshold_power_range(
229
+ start, end, sport="CYCLING"
230
+ ),
231
+ None,
232
+ {"from": start, "to": end, "sport": "CYCLING", "aggregation": "daily"},
233
+ )
234
+ if payload is not None:
235
+ for item in functional_threshold_power_items(payload):
236
+ series = str(item.get("series") or item.get("sport") or "").upper()
237
+ value = item.get("value", item.get("functionalThresholdPower"))
238
+ occurred_on = ftp_history_date(item)
239
+ if series != "CYCLING" or isinstance(value, bool) or not isinstance(value, (int, float)) or occurred_on is None:
240
+ continue
241
+ normalized = {
242
+ **item,
243
+ "sport": "CYCLING",
244
+ "calendarDate": occurred_on,
245
+ "functionalThresholdPower": value,
246
+ }
247
+ self.writer.source_entity(
248
+ "functional_threshold_power",
249
+ f"cycling:{occurred_on}",
250
+ normalized,
251
+ raw_hash,
252
+ occurred_on=occurred_on,
253
+ )
254
+ window_start = window_end + timedelta(days=1)
255
+
256
+ def _max_metrics_history(self, from_date: date, to_date: date) -> None:
257
+ """Stage range max-metric responses by their actual calendar date."""
258
+ window_start = from_date
259
+ while window_start <= to_date:
260
+ window_end = min(to_date, window_start + timedelta(days=89))
261
+ start, end = window_start.isoformat(), window_end.isoformat()
262
+ payload, raw_hash = self._capture(
263
+ "max_metrics_range",
264
+ lambda start=start, end=end: self.api.get_max_metrics_range(start, end),
265
+ None,
266
+ {"from": start, "to": end},
267
+ )
268
+ observed: set[str] = set()
269
+ if payload is not None:
270
+ for item in as_items(payload):
271
+ item_day = payload_date(item)
272
+ if item_day:
273
+ observed.add(item_day)
274
+ self.writer.source_entity("max_metric", f"max_metrics:{item_day}", item, raw_hash, occurred_on=item_day)
275
+ current = window_start
276
+ while current <= window_end:
277
+ day = current.isoformat()
278
+ if day not in observed:
279
+ fallback, fallback_hash = self._capture(
280
+ "max_metrics",
281
+ lambda day=day: self.api.get_max_metrics(day),
282
+ day,
283
+ {"date": day, "fallback": "range-missing"},
284
+ )
285
+ if fallback is not None:
286
+ for item in as_items(fallback):
287
+ self.writer.source_entity("max_metric", f"max_metrics:{day}", item, fallback_hash, occurred_on=day)
288
+ current += timedelta(days=1)
289
+ window_start = window_end + timedelta(days=1)
290
+
291
+ def _hrv_history(self, from_date: date, to_date: date) -> None:
292
+ """Stage the PR-402 HRV range API, retaining daily fallback coverage."""
293
+ window_start = from_date
294
+ while window_start <= to_date:
295
+ window_end = min(to_date, window_start + timedelta(days=89))
296
+ start, end = window_start.isoformat(), window_end.isoformat()
297
+ payload, raw_hash = self._capture(
298
+ "hrv_range",
299
+ lambda start=start, end=end: self.api.get_hrv_data_range(start, end),
300
+ None,
301
+ {"from": start, "to": end},
302
+ )
303
+ observed: set[str] = set()
304
+ if payload is not None:
305
+ for item in as_items(payload):
306
+ item_day = payload_date(item)
307
+ if item_day:
308
+ observed.add(item_day)
309
+ self.writer.source_entity("daily_health", f"hrv:{item_day}", item, raw_hash, occurred_on=item_day)
310
+ current = window_start
311
+ while current <= window_end:
312
+ day = current.isoformat()
313
+ if day not in observed:
314
+ fallback, fallback_hash = self._capture("hrv", lambda day=day: self.api.get_hrv_data(day), day, {"date": day, "fallback": "range-missing"})
315
+ if fallback is not None:
316
+ self.writer.source_entity("daily_health", f"hrv:{day}", as_dict(fallback), fallback_hash, occurred_on=day)
317
+ current += timedelta(days=1)
318
+ window_start = window_end + timedelta(days=1)
319
+
320
+ def _score_history(self, from_date: date, to_date: date) -> None:
321
+ """Stage daily-capable endurance-adjacent metrics from range endpoints."""
322
+ window_start = from_date
323
+ while window_start <= to_date:
324
+ window_end = min(to_date, window_start + timedelta(days=89))
325
+ start, end = window_start.isoformat(), window_end.isoformat()
326
+ for endpoint, method, entity_type in (
327
+ ("hill_score", lambda: self.api.get_hill_score(start, end), "hill_score"),
328
+ ("running_tolerance", lambda: self.api.get_running_tolerance(start, end, aggregation="daily"), "running_tolerance"),
329
+ ):
330
+ payload, raw_hash = self._capture(endpoint, method, None, {"from": start, "to": end, "aggregation": "daily"})
331
+ if payload is not None:
332
+ for index, item in enumerate(as_items(payload)):
333
+ item_day = payload_date(item)
334
+ if item_day:
335
+ self.writer.source_entity(entity_type, f"{endpoint}:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
336
+ window_start = window_end + timedelta(days=1)
337
+
338
+ # Garmin limits daily race-prediction windows to one year.
339
+ window_start = from_date
340
+ while window_start <= to_date:
341
+ window_end = min(to_date, window_start + timedelta(days=365))
342
+ start, end = window_start.isoformat(), window_end.isoformat()
343
+ payload, raw_hash = self._capture("race_predictions", lambda start=start, end=end: self.api.get_race_predictions(start, end, "daily"), None, {"from": start, "to": end, "aggregation": "daily"})
344
+ if payload is not None:
345
+ for index, item in enumerate(as_items(payload)):
346
+ item_day = payload_date(item) or start
347
+ self.writer.source_entity("race_prediction", f"race_prediction:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
348
+ window_start = window_end + timedelta(days=1)
349
+
350
+ def _activities(self, from_date: date, to_date: date) -> None:
351
+ start, end = from_date.isoformat(), to_date.isoformat()
352
+ payload, raw_hash = self._capture("activities", lambda: self.api.get_activities_by_date(start, end), None, {"from": start, "to": end})
353
+ if payload is None:
354
+ return
355
+ self._entity("activities", "activity", payload, raw_hash)
356
+ for summary in as_items(payload):
357
+ activity_id = remote_id(summary, "unknown")
358
+ summary_hash = hashlib.sha256(json.dumps(summary, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
359
+ details_fetched = self.known_activity_hashes.get(activity_id) != summary_hash
360
+ if details_fetched:
361
+ self._activity_details(activity_id, summary)
362
+ self.writer.activity_sync_state(activity_id, summary_hash, details_fetched)
363
+
364
+ def _multisport_children(self, parent_activity_id: str, activity: dict[str, Any]) -> None:
365
+ if not activity.get("isMultiSportParent"):
366
+ return
367
+ metadata = as_dict(activity.get("metadataDTO"))
368
+ child_ids = metadata.get("childIds")
369
+ if not isinstance(child_ids, list):
370
+ return
371
+ for child_id in dict.fromkeys(str(value) for value in child_ids if value is not None and str(value) != parent_activity_id):
372
+ payload, raw_hash = self._capture("activity", lambda child_id=child_id: self.api.get_activity(child_id), child_id, {"parentActivityId": parent_activity_id})
373
+ if isinstance(payload, dict):
374
+ self.writer.source_entity("activity", child_id, payload, raw_hash, parent_remote_id=parent_activity_id)
375
+
376
+ def _activity_details(self, activity_id: str, summary: dict[str, Any]) -> None:
377
+ calls = {
378
+ "activity": ("get_activity", "activity_detail"), "activity_details": ("get_activity_details", "activity_detail"),
379
+ "activity_splits": ("get_activity_splits", "activity_interval"), "activity_typed_splits": ("get_activity_typed_splits", "activity_interval"),
380
+ "activity_split_summaries": ("get_activity_split_summaries", "activity_interval"), "activity_weather": ("get_activity_weather", "activity_weather"),
381
+ "activity_hr_zones": ("get_activity_hr_in_timezones", "activity_zone"), "activity_power_zones": ("get_activity_power_in_timezones", "activity_zone"),
382
+ "activity_exercise_sets": ("get_activity_exercise_sets", "activity_exercise_set"), "activity_gear": ("get_activity_gear", "activity_gear"),
383
+ }
384
+ details: dict[str, Any] | None = None
385
+ for endpoint, (method, entity_type) in calls.items():
386
+ payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)(activity_id), activity_id)
387
+ if payload is not None:
388
+ self._entity(endpoint, entity_type, payload, raw_hash, parent_remote_id=activity_id)
389
+ if endpoint == "activity" and isinstance(payload, dict):
390
+ self._multisport_children(activity_id, payload)
391
+ if endpoint == "activity_details" and isinstance(payload, dict):
392
+ details = payload
393
+ self._activity_files(activity_id, summary, details)
394
+
395
+ def _activity_files(self, activity_id: str, summary: dict[str, Any], details: dict[str, Any] | None) -> None:
396
+ try:
397
+ from garminconnect import Garmin
398
+ formats = {
399
+ "activity_original": Garmin.ActivityDownloadFormat.ORIGINAL,
400
+ "activity_tcx": Garmin.ActivityDownloadFormat.TCX,
401
+ "activity_gpx": Garmin.ActivityDownloadFormat.GPX,
402
+ "activity_kml": Garmin.ActivityDownloadFormat.KML,
403
+ "activity_csv": Garmin.ActivityDownloadFormat.CSV,
404
+ }
405
+ except ImportError as error:
406
+ self.writer.error("activity_files", str(error), activity_id, False)
407
+ return
408
+ original_path: Path | None = None
409
+ original_hash: str | None = None
410
+ for endpoint, format_value in formats.items():
411
+ try:
412
+ contents = self.api.download_activity(activity_id, format_value)
413
+ digest = self.writer.archive_bytes(endpoint, activity_id, contents, "zip" if endpoint == "activity_original" else endpoint.rsplit("_", 1)[1])
414
+ if endpoint == "activity_original":
415
+ original_path = self.data_dir / "raw" / "garmin" / endpoint / activity_id / f"{digest}.zip"
416
+ original_hash = digest
417
+ except Exception as error:
418
+ self.writer.error(endpoint, str(error), activity_id)
419
+ samples = activity_details_to_samples(f"garmin:{activity_id}", details or {})
420
+ if not samples and original_path and original_path.exists():
421
+ samples = fit_archive_to_samples(f"garmin:{activity_id}", original_path)
422
+ if samples:
423
+ start_date = str(summary.get("startTimeLocal") or summary.get("startTimeGMT") or date.today().isoformat())
424
+ destination, digest, row_count = write_parquet(self.data_dir, activity_id, start_date, samples)
425
+ self.writer.emit({
426
+ "kind": "stream_manifest", "provider": "garmin", "activityRemoteId": activity_id,
427
+ "relativePath": str(destination.relative_to(self.data_dir)), "contentHash": digest, "rowCount": row_count,
428
+ "startAt": samples[0].get("timestamp_utc"), "endAt": samples[-1].get("timestamp_utc"),
429
+ "columns": SAMPLE_COLUMNS, "rawObjectHash": None,
430
+ })
431
+ for duration, best_power in activity_power_bests(samples).items():
432
+ self.writer.source_entity(
433
+ "activity_power_best",
434
+ f"{activity_id}:{duration}",
435
+ {"durationSeconds": duration, "bestPowerWatts": best_power, "sourceType": "garmin_fit_derived"},
436
+ original_hash,
437
+ parent_remote_id=activity_id,
438
+ occurred_on=start_date[:10],
439
+ )
440
+
441
+ def _collections(self) -> None:
442
+ today = date.today()
443
+ calls = {
444
+ "workouts": ("get_workouts", (), "workout"), "scheduled_workouts": ("get_scheduled_workouts", (today.year, today.month), "scheduled_workout"),
445
+ "goals": ("get_goals", (), "goal"), "golf_summary": ("get_golf_summary", (), "golf_scorecard"),
446
+ }
447
+ for endpoint, (method, arguments, entity_type) in calls.items():
448
+ payload, raw_hash = self._capture(endpoint, lambda method=method, arguments=arguments: getattr(self.api, method)(*arguments))
449
+ if payload is not None:
450
+ self._entity(endpoint, entity_type, payload, raw_hash)
@@ -0,0 +1,3 @@
1
+ """Targeted, read-only Strava staging worker for Catence."""
2
+
3
+ STAGING_SCHEMA_VERSION = 1