catence 0.2.0-beta.2 → 0.2.0-beta.21

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 (66) hide show
  1. package/README.md +28 -82
  2. package/config.example.json +25 -0
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +49 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/repository.d.ts +2 -0
  12. package/dist/core/query/repository.js +48 -0
  13. package/dist/core/runtime/catalog.js +17 -7
  14. package/dist/core/runtime/configuration.d.ts +3 -1
  15. package/dist/core/runtime/configuration.js +5 -1
  16. package/dist/core/runtime/opencode-go.d.ts +58 -0
  17. package/dist/core/runtime/opencode-go.js +246 -0
  18. package/dist/core/runtime/secrets.js +51 -13
  19. package/dist/core/runtime/self-update.d.ts +44 -0
  20. package/dist/core/runtime/self-update.js +138 -0
  21. package/dist/elt/application/detached-sync.d.ts +50 -0
  22. package/dist/elt/application/detached-sync.js +99 -0
  23. package/dist/elt/application/management.d.ts +15 -0
  24. package/dist/elt/application/management.js +435 -19
  25. package/dist/elt/ingestion/importer.d.ts +3 -2
  26. package/dist/elt/ingestion/importer.js +5 -4
  27. package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
  28. package/dist/elt/ingestion/providers/intervals.js +37 -2
  29. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  30. package/dist/elt/ingestion/providers/registry.js +18 -1
  31. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  32. package/dist/elt/normalization/normalizers.js +111 -15
  33. package/dist/elt/storage/database.d.ts +21 -2
  34. package/dist/elt/storage/database.js +184 -6
  35. package/dist/elt/storage/migrations.js +25 -1
  36. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  37. package/dist/elt/storage/progress-sidecar.js +200 -0
  38. package/dist/interfaces/cli/commands.js +116 -1
  39. package/dist/interfaces/http/generation-status.d.ts +19 -0
  40. package/dist/interfaces/http/generation-status.js +55 -0
  41. package/dist/interfaces/http/server.d.ts +7 -1
  42. package/dist/interfaces/http/server.js +184 -1
  43. package/dist/interfaces/mcp/server.d.ts +2 -1
  44. package/dist/interfaces/mcp/server.js +50 -13
  45. package/dist/runtime/index.d.ts +8 -1
  46. package/dist/runtime/index.js +4 -1
  47. package/docs/architecture.md +11 -1
  48. package/docs/configuration.md +243 -0
  49. package/docs/deployment/docker.md +528 -0
  50. package/docs/deployment/local-console.md +263 -0
  51. package/docs/deployment/local-mcp.md +329 -0
  52. package/docs/development.md +337 -0
  53. package/docs/distribution.md +58 -0
  54. package/docs/llm-providers.md +249 -6
  55. package/docs/mcp-activity-retrieval.md +37 -30
  56. package/package.json +5 -1
  57. package/pyproject.toml +1 -1
  58. package/python/catence/providers/garmin/cli.py +43 -9
  59. package/python/catence/providers/garmin/progress.py +85 -0
  60. package/python/catence/providers/garmin/registry.py +1 -0
  61. package/python/catence/providers/garmin/streams.py +38 -0
  62. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  63. package/python/catence/providers/garmin/worker.py +372 -11
  64. package/scripts/discover-opencode-go.mjs +102 -0
  65. package/scripts/discover-opencode-zen.mjs +218 -0
  66. package/uv.lock +3 -3
@@ -6,9 +6,14 @@ import hashlib
6
6
  import json
7
7
  from typing import Any, Callable, Mapping
8
8
 
9
+ from .progress import ProgressReporter
9
10
  from .registry import GARMIN_READ_METHODS, assert_read_only_registry
10
11
  from .staging import StagingWriter
11
- from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, write_parquet
12
+ from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, fit_archive_to_swim_lengths, write_parquet
13
+
14
+
15
+ class WorkerInterrupted(Exception):
16
+ """Raised between extraction steps when the run is asked to stop."""
12
17
 
13
18
 
14
19
  def as_dict(value: Any) -> dict[str, Any]:
@@ -39,6 +44,52 @@ def functional_threshold_power_items(value: Any) -> list[dict[str, Any]]:
39
44
  return []
40
45
 
41
46
 
47
+ def threshold_range_items(value: Any) -> list[dict[str, Any]]:
48
+ """Extract dated points from a lactate-threshold range series response."""
49
+ items = functional_threshold_power_items(value)
50
+ return [item for item in items if isinstance(item.get("value"), (int, float)) and not isinstance(item.get("value"), bool)]
51
+
52
+
53
+ def lactate_threshold_days(payload: Any) -> dict[str, dict[str, float]]:
54
+ """Combine speed/heart-rate and power range series into per-day thresholds.
55
+
56
+ The range responses carry no weight or power-to-weight fields; deriving
57
+ W/kg is left to downstream joins against daily weight history.
58
+ """
59
+ combined: dict[str, dict[str, float]] = {}
60
+ if not isinstance(payload, Mapping):
61
+ return combined
62
+ for block, key in (("speed", "speed"), ("heart_rate", "heartRate")):
63
+ for item in threshold_range_items(payload.get(block)):
64
+ day = ftp_history_date(item)
65
+ if day:
66
+ combined.setdefault(day, {})[key] = float(item["value"])
67
+ for item in threshold_range_items(payload.get("power")):
68
+ day = ftp_history_date(item)
69
+ if day:
70
+ combined.setdefault(day, {})["functionalThresholdPower"] = float(item["value"])
71
+ return combined
72
+
73
+
74
+ def lactate_threshold_payload(day: str, threshold: Mapping[str, float]) -> dict[str, Any]:
75
+ """Compose a singleton-shaped payload the normalizer already understands."""
76
+ observed_at = f"{day}T12:00:00"
77
+ payload: dict[str, Any] = {"calendarDate": observed_at}
78
+ speed = threshold.get("speed")
79
+ heart_rate = threshold.get("heartRate")
80
+ power = threshold.get("functionalThresholdPower")
81
+ if speed is not None or heart_rate is not None:
82
+ speed_and_heart_rate: dict[str, Any] = {"calendarDate": observed_at}
83
+ if speed is not None:
84
+ speed_and_heart_rate["speed"] = speed
85
+ if heart_rate is not None:
86
+ speed_and_heart_rate["heartRate"] = heart_rate
87
+ payload["speed_and_heart_rate"] = speed_and_heart_rate
88
+ if power is not None:
89
+ payload["power"] = {"calendarDate": observed_at, "functionalThresholdPower": power, "origin": "RUNNING"}
90
+ return payload
91
+
92
+
42
93
  def ftp_history_date(payload: Mapping[str, Any]) -> str | None:
43
94
  for key in ("until", "calendarDate", "date", "updatedDate", "from"):
44
95
  value = payload.get(key)
@@ -58,6 +109,25 @@ def remote_id(payload: dict[str, Any], fallback: str) -> str:
58
109
  return fallback
59
110
 
60
111
 
112
+ # Scheduled workouts are tiny calendar payloads (one GET per month), so a year
113
+ # of lookahead costs little next to a normal Garmin sync. Months back keep
114
+ # recently completed schedules associatable with their finished activities.
115
+ SCHEDULED_WORKOUTS_MONTHS_BACK = 3
116
+ SCHEDULED_WORKOUTS_MONTHS_AHEAD = 12
117
+
118
+ # Regular syncs reach back only far enough to associate recently finished
119
+ # events with their activities; deeper history arrives via the automatic
120
+ # coverage backfill (the parent passes --events-from when the store's own
121
+ # window predates this span).
122
+ EVENTS_PAST_DAYS = 30
123
+
124
+
125
+ def shift_month(base: date, offset: int) -> date:
126
+ """Return the first day of the month `offset` months from `base`'s month."""
127
+ total = base.year * 12 + (base.month - 1) + offset
128
+ return date(total // 12, total % 12 + 1, 1)
129
+
130
+
61
131
  def payload_date(payload: Mapping[str, Any]) -> str | None:
62
132
  """Return the first ISO calendar day exposed by a Garmin payload."""
63
133
  for key in ("calendarDate", "date", "until", "from", "timestamp", "timestampLocal", "startTimestampGMT"):
@@ -80,11 +150,23 @@ def payload_date(payload: Mapping[str, Any]) -> str | None:
80
150
  class GarminStagingWorker:
81
151
  """Extract Garmin data through the explicit read-only registry into JSONL/Parquet staging."""
82
152
 
83
- def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None) -> None:
153
+ def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None, progress: ProgressReporter | None = None) -> None:
84
154
  self.api = api
85
155
  self.writer = writer
86
156
  self.data_dir = data_dir
87
157
  self.known_activity_hashes = dict(known_activity_hashes or {})
158
+ self.progress = progress
159
+ self._interrupted = False
160
+
161
+ def request_interrupt(self) -> None:
162
+ """Ask the worker to stop at the next safe boundary."""
163
+ self._interrupted = True
164
+
165
+ def _check_interrupted(self) -> None:
166
+ if self._interrupted:
167
+ if self.progress is not None:
168
+ self.progress.finish("interrupted")
169
+ raise WorkerInterrupted("Sync run interrupted between extraction steps")
88
170
 
89
171
  def sync(
90
172
  self,
@@ -94,24 +176,43 @@ class GarminStagingWorker:
94
176
  daily_to_date: date | None = None,
95
177
  activity_to_date: date | None = None,
96
178
  include_non_historical: bool = True,
179
+ lactate_threshold_history_from: date | None = None,
180
+ events_from: str | None = None,
97
181
  ) -> None:
98
182
  assert_read_only_registry()
99
183
  end = to_date or date.today()
100
184
  daily_end = daily_to_date or end
101
185
  activity_end = activity_to_date or end
102
186
  if include_non_historical:
187
+ self._check_interrupted()
103
188
  self._singletons()
104
189
  if daily_from_date and daily_from_date <= daily_end:
190
+ self._check_interrupted()
105
191
  self._daily(daily_from_date, daily_end)
192
+ self._check_interrupted()
106
193
  self._range(daily_from_date, daily_end)
194
+ self._check_interrupted()
107
195
  self._cycling_ftp_history(daily_from_date, daily_end)
196
+ self._check_interrupted()
108
197
  self._max_metrics_history(daily_from_date, daily_end)
198
+ self._check_interrupted()
109
199
  self._hrv_history(daily_from_date, daily_end)
200
+ self._check_interrupted()
110
201
  self._score_history(daily_from_date, daily_end)
202
+ # Lactate-threshold history uses the widest requested window so a
203
+ # single sync can catch stores up that were created before per-day
204
+ # LT capture existed; runs even when the daily window is skipped.
205
+ lt_windows = [window for window in (daily_from_date, lactate_threshold_history_from) if window is not None]
206
+ lt_start = min(lt_windows) if lt_windows else None
207
+ if lt_start is not None and lt_start <= daily_end:
208
+ self._check_interrupted()
209
+ self._lactate_threshold_history(lt_start, daily_end)
111
210
  if activity_from_date and activity_from_date <= activity_end:
211
+ self._check_interrupted()
112
212
  self._activities(activity_from_date, activity_end)
113
213
  if include_non_historical:
114
- self._collections()
214
+ self._check_interrupted()
215
+ self._collections(events_from=events_from)
115
216
 
116
217
  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
218
  try:
@@ -162,10 +263,18 @@ class GarminStagingWorker:
162
263
  "golf_user_stats": ("get_golf_user_stats", "golf_user_stat"),
163
264
  "golf_club_stats": ("get_golf_club_stats", "golf_club_stat"),
164
265
  }
165
- for endpoint, (method, entity_type) in calls.items():
266
+ if self.progress is not None:
267
+ self.progress.set_stage("singletons")
268
+ self.progress.advance(total=len(calls))
269
+ for index, (endpoint, (method, entity_type)) in enumerate(calls.items(), start=1):
270
+ self._check_interrupted()
271
+ if self.progress is not None:
272
+ self.progress.advance(step=endpoint)
166
273
  payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)())
167
274
  if payload is not None:
168
275
  self._entity(endpoint, entity_type, payload, raw_hash)
276
+ if self.progress is not None:
277
+ self.progress.advance(completed=index)
169
278
 
170
279
  def _daily(self, from_date: date, to_date: date) -> None:
171
280
  calls = {
@@ -183,13 +292,26 @@ class GarminStagingWorker:
183
292
  "nutrition_food_log": ("get_nutrition_daily_food_log", "nutrition_log"), "nutrition_meals": ("get_nutrition_daily_meals", "nutrition_log"),
184
293
  "nutrition_settings": ("get_nutrition_daily_settings", "nutrition_setting"),
185
294
  }
295
+ total_days = (to_date - from_date).days + 1
296
+ if self.progress is not None:
297
+ self.progress.set_stage("daily")
298
+ self.progress.advance(total=total_days)
186
299
  current = from_date
300
+ completed_days = 0
187
301
  while current <= to_date:
302
+ self._check_interrupted()
188
303
  day = current.isoformat()
304
+ if self.progress is not None:
305
+ self.progress.advance(step=day)
189
306
  for endpoint, (method, entity_type) in calls.items():
307
+ if self.progress is not None:
308
+ self.progress.advance(step=f"{day} {endpoint}")
190
309
  payload, raw_hash = self._capture(endpoint, lambda method=method, day=day: getattr(self.api, method)(day), day, {"date": day})
191
310
  if payload is not None:
192
311
  self._entity(endpoint, entity_type, payload, raw_hash, day)
312
+ completed_days += 1
313
+ if self.progress is not None:
314
+ self.progress.advance(completed=completed_days)
193
315
  current += timedelta(days=1)
194
316
 
195
317
  def _range(self, from_date: date, to_date: date) -> None:
@@ -198,7 +320,10 @@ class GarminStagingWorker:
198
320
  "blood_pressure": ("get_blood_pressure", "blood_pressure"), "weigh_ins": ("get_weigh_ins", "body_composition"),
199
321
  "progress_summary": ("get_progress_summary_between_dates", "progress_summary"), "menstrual_calendar": ("get_menstrual_calendar_data", "menstrual"),
200
322
  }
201
- for endpoint, (method, entity_type) in calls.items():
323
+ if self.progress is not None:
324
+ self.progress.set_stage("range")
325
+ self.progress.advance(total=len(calls))
326
+ for index, (endpoint, (method, entity_type)) in enumerate(calls.items(), start=1):
202
327
  # Garmin rejects broad body-battery and menstrual-calendar ranges.
203
328
  # Keep every request inside the provider's 92-day limit.
204
329
  window_start = from_date
@@ -206,23 +331,36 @@ class GarminStagingWorker:
206
331
  # ranges. Keep it deliberately small; the captures are
207
332
  # content-addressed so overlapping retry windows are harmless.
208
333
  window_days = 7 if endpoint == "body_battery" else (90 if endpoint == "menstrual_calendar" else (to_date - from_date).days + 1)
334
+ if self.progress is not None:
335
+ self.progress.advance(step=endpoint)
209
336
  while window_start <= to_date:
337
+ self._check_interrupted()
210
338
  window_end = min(to_date, window_start + timedelta(days=window_days - 1))
211
339
  start, end = window_start.isoformat(), window_end.isoformat()
212
340
  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
341
  if payload is not None:
214
342
  self._entity(endpoint, entity_type, payload, raw_hash, fallback_scope=start)
215
343
  window_start = window_end + timedelta(days=1)
344
+ if self.progress is not None:
345
+ self.progress.advance(completed=index)
216
346
 
217
347
  def _cycling_ftp_history(self, from_date: date, to_date: date) -> None:
218
348
  """Stage daily cycling FTP setting history from Garmin's range endpoint."""
219
349
  endpoint = "functional_threshold_power_range"
220
350
  window_start = from_date
351
+ total_windows = (to_date - from_date).days // 89 + 1
352
+ if self.progress is not None:
353
+ self.progress.set_stage("ftp_history")
354
+ self.progress.advance(total=total_windows)
355
+ completed_windows = 0
221
356
  while window_start <= to_date:
357
+ self._check_interrupted()
222
358
  # This private Connect endpoint is not documented with a maximum
223
359
  # range. Keep requests aligned with other conservative range calls.
224
360
  window_end = min(to_date, window_start + timedelta(days=89))
225
361
  start, end = window_start.isoformat(), window_end.isoformat()
362
+ if self.progress is not None:
363
+ self.progress.advance(step=start)
226
364
  payload, raw_hash = self._capture(
227
365
  endpoint,
228
366
  lambda start=start, end=end: self.api.get_functional_threshold_power_range(
@@ -251,14 +389,60 @@ class GarminStagingWorker:
251
389
  raw_hash,
252
390
  occurred_on=occurred_on,
253
391
  )
392
+ completed_windows += 1
393
+ if self.progress is not None:
394
+ self.progress.advance(completed=completed_windows)
395
+ window_start = window_end + timedelta(days=1)
396
+
397
+ def _lactate_threshold_history(self, from_date: date, to_date: date) -> None:
398
+ """Stage daily running lactate-threshold history from Garmin's range endpoints."""
399
+ window_start = from_date
400
+ total_windows = (to_date - from_date).days // 365 + 1
401
+ if self.progress is not None:
402
+ self.progress.set_stage("lt_history")
403
+ self.progress.advance(total=total_windows)
404
+ completed_windows = 0
405
+ while window_start <= to_date:
406
+ self._check_interrupted()
407
+ # Garmin rejects range windows longer than 366 days.
408
+ window_end = min(to_date, window_start + timedelta(days=365))
409
+ start, end = window_start.isoformat(), window_end.isoformat()
410
+ if self.progress is not None:
411
+ self.progress.advance(step=start)
412
+ payload, raw_hash = self._capture(
413
+ "lactate_threshold_range",
414
+ lambda start=start, end=end: self.api.get_lactate_threshold(latest=False, start_date=start, end_date=end, aggregation="daily"),
415
+ None,
416
+ {"from": start, "to": end, "aggregation": "daily"},
417
+ )
418
+ if payload is not None and raw_hash is not None:
419
+ for item_day, threshold in lactate_threshold_days(payload).items():
420
+ self.writer.source_entity(
421
+ "lactate_threshold",
422
+ f"daily:{item_day}",
423
+ lactate_threshold_payload(item_day, threshold),
424
+ raw_hash,
425
+ occurred_on=item_day,
426
+ )
427
+ completed_windows += 1
428
+ if self.progress is not None:
429
+ self.progress.advance(completed=completed_windows)
254
430
  window_start = window_end + timedelta(days=1)
255
431
 
256
432
  def _max_metrics_history(self, from_date: date, to_date: date) -> None:
257
433
  """Stage range max-metric responses by their actual calendar date."""
258
434
  window_start = from_date
435
+ total_windows = (to_date - from_date).days // 89 + 1
436
+ if self.progress is not None:
437
+ self.progress.set_stage("max_metrics")
438
+ self.progress.advance(total=total_windows)
439
+ completed_windows = 0
259
440
  while window_start <= to_date:
441
+ self._check_interrupted()
260
442
  window_end = min(to_date, window_start + timedelta(days=89))
261
443
  start, end = window_start.isoformat(), window_end.isoformat()
444
+ if self.progress is not None:
445
+ self.progress.advance(step=start)
262
446
  payload, raw_hash = self._capture(
263
447
  "max_metrics_range",
264
448
  lambda start=start, end=end: self.api.get_max_metrics_range(start, end),
@@ -286,14 +470,25 @@ class GarminStagingWorker:
286
470
  for item in as_items(fallback):
287
471
  self.writer.source_entity("max_metric", f"max_metrics:{day}", item, fallback_hash, occurred_on=day)
288
472
  current += timedelta(days=1)
473
+ completed_windows += 1
474
+ if self.progress is not None:
475
+ self.progress.advance(completed=completed_windows)
289
476
  window_start = window_end + timedelta(days=1)
290
477
 
291
478
  def _hrv_history(self, from_date: date, to_date: date) -> None:
292
479
  """Stage the PR-402 HRV range API, retaining daily fallback coverage."""
293
480
  window_start = from_date
481
+ total_windows = (to_date - from_date).days // 89 + 1
482
+ if self.progress is not None:
483
+ self.progress.set_stage("hrv_history")
484
+ self.progress.advance(total=total_windows)
485
+ completed_windows = 0
294
486
  while window_start <= to_date:
487
+ self._check_interrupted()
295
488
  window_end = min(to_date, window_start + timedelta(days=89))
296
489
  start, end = window_start.isoformat(), window_end.isoformat()
490
+ if self.progress is not None:
491
+ self.progress.advance(step=start)
297
492
  payload, raw_hash = self._capture(
298
493
  "hrv_range",
299
494
  lambda start=start, end=end: self.api.get_hrv_data_range(start, end),
@@ -315,14 +510,25 @@ class GarminStagingWorker:
315
510
  if fallback is not None:
316
511
  self.writer.source_entity("daily_health", f"hrv:{day}", as_dict(fallback), fallback_hash, occurred_on=day)
317
512
  current += timedelta(days=1)
513
+ completed_windows += 1
514
+ if self.progress is not None:
515
+ self.progress.advance(completed=completed_windows)
318
516
  window_start = window_end + timedelta(days=1)
319
517
 
320
518
  def _score_history(self, from_date: date, to_date: date) -> None:
321
519
  """Stage daily-capable endurance-adjacent metrics from range endpoints."""
322
520
  window_start = from_date
521
+ total_windows = (to_date - from_date).days // 89 + 1
522
+ if self.progress is not None:
523
+ self.progress.set_stage("scores")
524
+ self.progress.advance(total=total_windows)
525
+ completed_windows = 0
323
526
  while window_start <= to_date:
527
+ self._check_interrupted()
324
528
  window_end = min(to_date, window_start + timedelta(days=89))
325
529
  start, end = window_start.isoformat(), window_end.isoformat()
530
+ if self.progress is not None:
531
+ self.progress.advance(step=start)
326
532
  for endpoint, method, entity_type in (
327
533
  ("hill_score", lambda: self.api.get_hill_score(start, end), "hill_score"),
328
534
  ("running_tolerance", lambda: self.api.get_running_tolerance(start, end, aggregation="daily"), "running_tolerance"),
@@ -333,33 +539,56 @@ class GarminStagingWorker:
333
539
  item_day = payload_date(item)
334
540
  if item_day:
335
541
  self.writer.source_entity(entity_type, f"{endpoint}:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
542
+ completed_windows += 1
543
+ if self.progress is not None:
544
+ self.progress.advance(completed=completed_windows)
336
545
  window_start = window_end + timedelta(days=1)
337
546
 
338
547
  # Garmin limits daily race-prediction windows to one year.
339
548
  window_start = from_date
549
+ total_windows = (to_date - from_date).days // 365 + 1
550
+ if self.progress is not None:
551
+ self.progress.advance(total=total_windows)
552
+ completed_windows = 0
340
553
  while window_start <= to_date:
554
+ self._check_interrupted()
341
555
  window_end = min(to_date, window_start + timedelta(days=365))
342
556
  start, end = window_start.isoformat(), window_end.isoformat()
557
+ if self.progress is not None:
558
+ self.progress.advance(step=start)
343
559
  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
560
  if payload is not None:
345
561
  for index, item in enumerate(as_items(payload)):
346
562
  item_day = payload_date(item) or start
347
563
  self.writer.source_entity("race_prediction", f"race_prediction:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
564
+ completed_windows += 1
565
+ if self.progress is not None:
566
+ self.progress.advance(completed=completed_windows)
348
567
  window_start = window_end + timedelta(days=1)
349
568
 
350
569
  def _activities(self, from_date: date, to_date: date) -> None:
351
570
  start, end = from_date.isoformat(), to_date.isoformat()
571
+ if self.progress is not None:
572
+ self.progress.set_stage("activities")
352
573
  payload, raw_hash = self._capture("activities", lambda: self.api.get_activities_by_date(start, end), None, {"from": start, "to": end})
353
574
  if payload is None:
354
575
  return
355
576
  self._entity("activities", "activity", payload, raw_hash)
356
- for summary in as_items(payload):
577
+ summaries = as_items(payload)
578
+ if self.progress is not None:
579
+ self.progress.advance(total=len(summaries))
580
+ for index, summary in enumerate(summaries, start=1):
581
+ self._check_interrupted()
357
582
  activity_id = remote_id(summary, "unknown")
583
+ if self.progress is not None:
584
+ self.progress.advance(step=activity_id)
358
585
  summary_hash = hashlib.sha256(json.dumps(summary, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
359
586
  details_fetched = self.known_activity_hashes.get(activity_id) != summary_hash
360
587
  if details_fetched:
361
588
  self._activity_details(activity_id, summary)
362
589
  self.writer.activity_sync_state(activity_id, summary_hash, details_fetched)
590
+ if self.progress is not None:
591
+ self.progress.advance(completed=index)
363
592
 
364
593
  def _multisport_children(self, parent_activity_id: str, activity: dict[str, Any]) -> None:
365
594
  if not activity.get("isMultiSportParent"):
@@ -373,6 +602,49 @@ class GarminStagingWorker:
373
602
  if isinstance(payload, dict):
374
603
  self.writer.source_entity("activity", child_id, payload, raw_hash, parent_remote_id=parent_activity_id)
375
604
 
605
+ def _is_swim_activity(self, summary: dict[str, Any], activity: dict[str, Any] | None, details: dict[str, Any] | None) -> bool:
606
+ """Return True when any of the Garmin sport/type fields indicates a swim."""
607
+ candidates: list[str] = []
608
+ # Direct sport string fields on the list summary (including flat activityType string).
609
+ for key in ("sport", "sportType", "sport_type", "activityType", "activityTypeKey"):
610
+ value = summary.get(key)
611
+ if isinstance(value, str) and value:
612
+ candidates.append(value)
613
+ # Nested activityType shapes on the list summary.
614
+ for key in ("activityType", "activityTypeDTO"):
615
+ nested = summary.get(key)
616
+ if isinstance(nested, dict):
617
+ type_key = nested.get("typeKey") or nested.get("type_key") or nested.get("type") or nested.get("key")
618
+ if isinstance(type_key, str) and type_key:
619
+ candidates.append(type_key)
620
+ # Detailed payloads provide the authoritative typeKey.
621
+ for payload in (activity, details):
622
+ if not isinstance(payload, dict):
623
+ continue
624
+ for key in ("activityType", "activityTypeDTO"):
625
+ nested = payload.get(key)
626
+ if isinstance(nested, dict):
627
+ type_key = nested.get("typeKey")
628
+ if isinstance(type_key, str) and type_key:
629
+ candidates.append(type_key)
630
+ for key in ("sport", "sportType"):
631
+ value = payload.get(key)
632
+ if isinstance(value, str) and value:
633
+ candidates.append(value)
634
+ summary_dto = payload.get("summaryDTO")
635
+ if isinstance(summary_dto, dict):
636
+ for key in ("activityType", "activityTypeDTO"):
637
+ nested = summary_dto.get(key)
638
+ if isinstance(nested, dict):
639
+ type_key = nested.get("typeKey")
640
+ if isinstance(type_key, str) and type_key:
641
+ candidates.append(type_key)
642
+ for candidate in candidates:
643
+ lowered = candidate.lower()
644
+ if lowered == "lap_swimming" or "swim" in lowered:
645
+ return True
646
+ return False
647
+
376
648
  def _activity_details(self, activity_id: str, summary: dict[str, Any]) -> None:
377
649
  calls = {
378
650
  "activity": ("get_activity", "activity_detail"), "activity_details": ("get_activity_details", "activity_detail"),
@@ -382,17 +654,19 @@ class GarminStagingWorker:
382
654
  "activity_exercise_sets": ("get_activity_exercise_sets", "activity_exercise_set"), "activity_gear": ("get_activity_gear", "activity_gear"),
383
655
  }
384
656
  details: dict[str, Any] | None = None
657
+ activity_payload: dict[str, Any] | None = None
385
658
  for endpoint, (method, entity_type) in calls.items():
386
659
  payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)(activity_id), activity_id)
387
660
  if payload is not None:
388
661
  self._entity(endpoint, entity_type, payload, raw_hash, parent_remote_id=activity_id)
389
662
  if endpoint == "activity" and isinstance(payload, dict):
663
+ activity_payload = payload
390
664
  self._multisport_children(activity_id, payload)
391
665
  if endpoint == "activity_details" and isinstance(payload, dict):
392
666
  details = payload
393
- self._activity_files(activity_id, summary, details)
667
+ self._activity_files(activity_id, summary, activity_payload, details)
394
668
 
395
- def _activity_files(self, activity_id: str, summary: dict[str, Any], details: dict[str, Any] | None) -> None:
669
+ def _activity_files(self, activity_id: str, summary: dict[str, Any], activity: dict[str, Any] | None, details: dict[str, Any] | None) -> None:
396
670
  try:
397
671
  from garminconnect import Garmin
398
672
  formats = {
@@ -437,14 +711,101 @@ class GarminStagingWorker:
437
711
  parent_remote_id=activity_id,
438
712
  occurred_on=start_date[:10],
439
713
  )
714
+ if original_path and original_path.exists():
715
+ if self._is_swim_activity(summary, activity, details):
716
+ lengths = fit_archive_to_swim_lengths(f"garmin:{activity_id}", original_path)
717
+ if lengths:
718
+ start_date = str(summary.get("startTimeLocal") or summary.get("startTimeGMT") or date.today().isoformat())
719
+ self.writer.source_entity(
720
+ "activity_swim_lengths",
721
+ f"{activity_id}:fit",
722
+ {"lengths": lengths},
723
+ original_hash,
724
+ parent_remote_id=activity_id,
725
+ occurred_on=start_date[:10],
726
+ )
440
727
 
441
- def _collections(self) -> None:
728
+ def _fetch_garmin_events(self, start_date: str) -> None:
729
+ """Fetch calendar events from start_date, following pagination."""
730
+ page = 1
731
+ while True:
732
+ payload, raw_hash = self._capture(
733
+ "garmin_events",
734
+ lambda page=page: self.api.get_events(start_date, limit=100, page=page),
735
+ scope={"startDate": start_date, "page": page},
736
+ )
737
+ items = payload if isinstance(payload, list) else []
738
+ if items:
739
+ self._entity("garmin_events", "event", items, raw_hash)
740
+ if len(items) < 100:
741
+ return
742
+ page += 1
743
+
744
+ def _collections(self, events_from: str | None = None) -> None:
442
745
  today = date.today()
443
746
  calls = {
444
- "workouts": ("get_workouts", (), "workout"), "scheduled_workouts": ("get_scheduled_workouts", (today.year, today.month), "scheduled_workout"),
747
+ "workouts": ("get_workouts", (), "workout"),
445
748
  "goals": ("get_goals", (), "goal"), "golf_summary": ("get_golf_summary", (), "golf_scorecard"),
446
749
  }
447
- for endpoint, (method, arguments, entity_type) in calls.items():
750
+ months = [shift_month(today, offset) for offset in range(-SCHEDULED_WORKOUTS_MONTHS_BACK, SCHEDULED_WORKOUTS_MONTHS_AHEAD + 1)]
751
+ events_start = events_from or (today - timedelta(days=EVENTS_PAST_DAYS)).isoformat()
752
+ if self.progress is not None:
753
+ self.progress.set_stage("collections")
754
+ self.progress.advance(total=len(calls) + len(months) + 1)
755
+ for index, (endpoint, (method, arguments, entity_type)) in enumerate(calls.items(), start=1):
756
+ self._check_interrupted()
757
+ if self.progress is not None:
758
+ self.progress.advance(step=endpoint)
448
759
  payload, raw_hash = self._capture(endpoint, lambda method=method, arguments=arguments: getattr(self.api, method)(*arguments))
449
760
  if payload is not None:
450
761
  self._entity(endpoint, entity_type, payload, raw_hash)
762
+ if self.progress is not None:
763
+ self.progress.advance(completed=index)
764
+ # Scheduled workouts span past-to-future months so upcoming sessions
765
+ # surface ahead of time and recently completed ones stay associatable
766
+ # with their finished activities. The calendar service answers each
767
+ # month with a grid wrapper whose items live under `calendarItems`;
768
+ # empty months must stage nothing, and real items stage one row each
769
+ # with their own calendar date.
770
+ for month_index, month in enumerate(months, start=1):
771
+ self._check_interrupted()
772
+ payload, raw_hash = self._capture(
773
+ "scheduled_workouts",
774
+ lambda month=month: self.api.get_scheduled_workouts(month.year, month.month),
775
+ scope={"month": month.isoformat()},
776
+ )
777
+ items = payload.get("calendarItems") if isinstance(payload, Mapping) else payload
778
+ # itemType 'activity' entries are echoes of already-recorded
779
+ # activities (the activities endpoint owns those); only true
780
+ # scheduled workout templates belong in this entity family.
781
+ if items:
782
+ items = [item for item in items if isinstance(item, Mapping) and item.get("itemType") == "workout"]
783
+ if items:
784
+ self._entity("scheduled_workouts", "scheduled_workout", items, raw_hash)
785
+ if self.progress is not None:
786
+ self.progress.advance(completed=len(calls) + month_index)
787
+ # Joined events (races etc.) look back only far enough to associate
788
+ # finished activities; the parent widens start_date into a one-time
789
+ # coverage backfill when the store predates this span.
790
+ self._check_interrupted()
791
+ if self.progress is not None:
792
+ self.progress.advance(step="garmin_events")
793
+ self._fetch_garmin_events(events_start)
794
+ if self.progress is not None:
795
+ self.progress.advance(completed=len(calls) + len(months) + 1)
796
+
797
+ def _fetch_garmin_events(self, start_date: str) -> None:
798
+ """Fetch calendar events from start_date, following pagination."""
799
+ page = 1
800
+ while True:
801
+ payload, raw_hash = self._capture(
802
+ "garmin_events",
803
+ lambda page=page: self.api.get_events(start_date, limit=100, page=page),
804
+ scope={"startDate": start_date, "page": page},
805
+ )
806
+ items = payload if isinstance(payload, list) else []
807
+ if items:
808
+ self._entity("garmin_events", "event", items, raw_hash)
809
+ if len(items) < 100:
810
+ return
811
+ page += 1