catence 0.2.0-beta.1 → 0.2.0-beta.10

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 (38) hide show
  1. package/README.md +144 -2
  2. package/config.example.json +32 -20
  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 +48 -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 +42 -0
  13. package/dist/core/runtime/catalog.js +17 -7
  14. package/dist/core/runtime/self-update.d.ts +44 -0
  15. package/dist/core/runtime/self-update.js +138 -0
  16. package/dist/elt/application/management.d.ts +2 -0
  17. package/dist/elt/application/management.js +194 -17
  18. package/dist/elt/ingestion/importer.d.ts +3 -2
  19. package/dist/elt/ingestion/importer.js +4 -3
  20. package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
  21. package/dist/elt/ingestion/providers/intervals.js +4 -1
  22. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  23. package/dist/elt/storage/database.d.ts +7 -2
  24. package/dist/elt/storage/database.js +81 -4
  25. package/dist/elt/storage/migrations.js +25 -1
  26. package/dist/interfaces/cli/commands.js +91 -1
  27. package/dist/interfaces/mcp/server.js +7 -0
  28. package/dist/runtime/index.d.ts +4 -1
  29. package/dist/runtime/index.js +2 -1
  30. package/docs/deployment.md +338 -0
  31. package/docs/docker-deployment.md +232 -0
  32. package/docs/llm-providers.md +467 -0
  33. package/package.json +5 -1
  34. package/python/catence/providers/garmin/cli.py +30 -9
  35. package/python/catence/providers/garmin/progress.py +85 -0
  36. package/python/catence/providers/garmin/worker.py +132 -5
  37. package/scripts/discover-opencode-go.mjs +210 -0
  38. package/scripts/discover-opencode-zen.mjs +218 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.1",
3
+ "version": "0.2.0-beta.10",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -28,6 +28,8 @@
28
28
  "README.md",
29
29
  "config.example.json",
30
30
  "docs",
31
+ "scripts/discover-opencode-go.mjs",
32
+ "scripts/discover-opencode-zen.mjs",
31
33
  "pyproject.toml",
32
34
  "uv.lock",
33
35
  "python/__init__.py",
@@ -55,6 +57,8 @@
55
57
  "mcp": "tsx src/interfaces/mcp/main.ts",
56
58
  "console": "uv run --project console catence-console serve",
57
59
  "console:doctor": "uv run --project console catence-console doctor",
60
+ "discover:opencode-go": "node scripts/discover-opencode-go.mjs",
61
+ "discover:opencode-zen": "node scripts/discover-opencode-zen.mjs",
58
62
  "package:check": "npm pack --dry-run",
59
63
  "release:check": "node scripts/release-artifacts.mjs check",
60
64
  "mcpb:validate": "npm exec --yes --package @anthropic-ai/mcpb -- mcpb validate mcpb/manifest.json",
@@ -4,11 +4,14 @@ import argparse
4
4
  import getpass
5
5
  import json
6
6
  import os
7
+ import signal
8
+ import sys
7
9
  from datetime import date
8
10
  from pathlib import Path
9
11
 
12
+ from .progress import ProgressReporter
10
13
  from .staging import StagingWriter
11
- from .worker import GarminStagingWorker
14
+ from .worker import GarminStagingWorker, WorkerInterrupted
12
15
 
13
16
 
14
17
  def parse_args() -> argparse.Namespace:
@@ -43,19 +46,37 @@ def main() -> None:
43
46
  token_store.parent.mkdir(parents=True, exist_ok=True)
44
47
  client = Garmin(email, password, prompt_mfa=lambda: input("Garmin MFA code: "))
45
48
  client.login(str(token_store))
49
+ reporter = ProgressReporter(args.run_id)
50
+ reporter.set_stage("login")
51
+ reporter.finish("login")
46
52
  known_hashes: dict[str, str] = {}
47
53
  if args.known_activities and args.known_activities.exists():
48
54
  loaded = json.loads(args.known_activities.read_text(encoding="utf-8"))
49
55
  if isinstance(loaded, dict):
50
56
  known_hashes = {str(key): str(value) for key, value in loaded.items()}
51
- GarminStagingWorker(client, writer, args.data_dir, known_hashes).sync(
52
- daily_from_date=None if args.skip_daily else args.daily_from_date or args.from_date,
53
- activity_from_date=None if args.skip_activities else args.activity_from_date or args.from_date,
54
- to_date=args.to_date,
55
- daily_to_date=args.daily_to_date,
56
- activity_to_date=args.activity_to_date,
57
- include_non_historical=not args.historical_only,
58
- )
57
+ worker = GarminStagingWorker(client, writer, args.data_dir, known_hashes, progress=reporter)
58
+ interrupt_signal: int | None = None
59
+
60
+ def _request_interrupt(signum: int, _frame: object) -> None:
61
+ nonlocal interrupt_signal
62
+ interrupt_signal = signum
63
+ worker.request_interrupt()
64
+
65
+ signal.signal(signal.SIGINT, _request_interrupt)
66
+ signal.signal(signal.SIGTERM, _request_interrupt)
67
+ try:
68
+ worker.sync(
69
+ daily_from_date=None if args.skip_daily else args.daily_from_date or args.from_date,
70
+ activity_from_date=None if args.skip_activities else args.activity_from_date or args.from_date,
71
+ to_date=args.to_date,
72
+ daily_to_date=args.daily_to_date,
73
+ activity_to_date=args.activity_to_date,
74
+ include_non_historical=not args.historical_only,
75
+ )
76
+ except WorkerInterrupted:
77
+ reporter.finish("interrupted")
78
+ raise SystemExit(130 if interrupt_signal == signal.SIGINT else 143)
79
+ reporter.finish("completed")
59
80
 
60
81
 
61
82
  if __name__ == "__main__":
@@ -0,0 +1,85 @@
1
+ """Progress reporting for Garmin sync runs.
2
+
3
+ Emits single-line JSON progress records (kind: 'progress') on stdout so the
4
+ Node management layer can persist heartbeats without parsing the staging
5
+ JSONL consumed by the importer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Callable
14
+
15
+
16
+ class ProgressReporter:
17
+ """Throttled progress publisher writing NDJSON records to stdout."""
18
+
19
+ def __init__(
20
+ self,
21
+ run_id: str,
22
+ provider: str = "garmin",
23
+ emit: Callable[[str], None] = lambda line: print(line, flush=True),
24
+ interval_seconds: float = 2.0,
25
+ ) -> None:
26
+ self.run_id = run_id
27
+ self.provider = provider
28
+ self.emit = emit
29
+ self.interval_seconds = interval_seconds
30
+ self._started = time.monotonic()
31
+ self._last_emitted = 0.0
32
+ self.stage = "starting"
33
+ self.current_step: str | None = None
34
+ self.completed_units = 0
35
+ self.total_units: int | None = None
36
+
37
+ def set_stage(self, stage: str) -> None:
38
+ """Switch phases, resetting phase-local counters and forcing a publish."""
39
+ if stage != self.stage:
40
+ self.stage = stage
41
+ self.current_step = None
42
+ self.completed_units = 0
43
+ self.total_units = None
44
+ self.publish(force=True)
45
+
46
+ def advance(self, completed: int | None = None, total: int | None = None, step: str | None = None) -> None:
47
+ if completed is not None:
48
+ self.completed_units = completed
49
+ if total is not None:
50
+ self.total_units = total
51
+ if step is not None:
52
+ self.current_step = step
53
+ self.publish()
54
+
55
+ def publish(self, force: bool = False) -> None:
56
+ now = time.monotonic()
57
+ if not force and now - self._last_emitted < self.interval_seconds:
58
+ return
59
+ self._last_emitted = now
60
+ elapsed = now - self._started
61
+ percent = 0.0
62
+ eta: float | None = None
63
+ if self.total_units and self.total_units > 0:
64
+ percent = min(100.0, max(0.0, self.completed_units / self.total_units * 100.0))
65
+ if self.completed_units > 0 and self.completed_units < self.total_units:
66
+ rate = elapsed / self.completed_units
67
+ eta = rate * (self.total_units - self.completed_units)
68
+ record = {
69
+ "kind": "progress",
70
+ "runId": self.run_id,
71
+ "provider": self.provider,
72
+ "stage": self.stage,
73
+ "currentStep": self.current_step,
74
+ "completedUnits": self.completed_units,
75
+ "totalUnits": self.total_units,
76
+ "percentComplete": round(percent, 2),
77
+ "elapsedSeconds": round(elapsed, 1),
78
+ "estimatedRemainingSeconds": round(eta, 1) if eta is not None else None,
79
+ "heartbeatAt": datetime.now(timezone.utc).isoformat(),
80
+ }
81
+ self.emit(json.dumps(record, separators=(",", ":")))
82
+
83
+ def finish(self, stage: str = "completed") -> None:
84
+ self.stage = stage
85
+ self.publish(force=True)
@@ -6,11 +6,16 @@ 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
12
  from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, write_parquet
12
13
 
13
14
 
15
+ class WorkerInterrupted(Exception):
16
+ """Raised between extraction steps when the run is asked to stop."""
17
+
18
+
14
19
  def as_dict(value: Any) -> dict[str, Any]:
15
20
  return value if isinstance(value, dict) else {"value": value}
16
21
 
@@ -80,11 +85,23 @@ def payload_date(payload: Mapping[str, Any]) -> str | None:
80
85
  class GarminStagingWorker:
81
86
  """Extract Garmin data through the explicit read-only registry into JSONL/Parquet staging."""
82
87
 
83
- def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None) -> None:
88
+ def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None, progress: ProgressReporter | None = None) -> None:
84
89
  self.api = api
85
90
  self.writer = writer
86
91
  self.data_dir = data_dir
87
92
  self.known_activity_hashes = dict(known_activity_hashes or {})
93
+ self.progress = progress
94
+ self._interrupted = False
95
+
96
+ def request_interrupt(self) -> None:
97
+ """Ask the worker to stop at the next safe boundary."""
98
+ self._interrupted = True
99
+
100
+ def _check_interrupted(self) -> None:
101
+ if self._interrupted:
102
+ if self.progress is not None:
103
+ self.progress.finish("interrupted")
104
+ raise WorkerInterrupted("Sync run interrupted between extraction steps")
88
105
 
89
106
  def sync(
90
107
  self,
@@ -100,17 +117,26 @@ class GarminStagingWorker:
100
117
  daily_end = daily_to_date or end
101
118
  activity_end = activity_to_date or end
102
119
  if include_non_historical:
120
+ self._check_interrupted()
103
121
  self._singletons()
104
122
  if daily_from_date and daily_from_date <= daily_end:
123
+ self._check_interrupted()
105
124
  self._daily(daily_from_date, daily_end)
125
+ self._check_interrupted()
106
126
  self._range(daily_from_date, daily_end)
127
+ self._check_interrupted()
107
128
  self._cycling_ftp_history(daily_from_date, daily_end)
129
+ self._check_interrupted()
108
130
  self._max_metrics_history(daily_from_date, daily_end)
131
+ self._check_interrupted()
109
132
  self._hrv_history(daily_from_date, daily_end)
133
+ self._check_interrupted()
110
134
  self._score_history(daily_from_date, daily_end)
111
135
  if activity_from_date and activity_from_date <= activity_end:
136
+ self._check_interrupted()
112
137
  self._activities(activity_from_date, activity_end)
113
138
  if include_non_historical:
139
+ self._check_interrupted()
114
140
  self._collections()
115
141
 
116
142
  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]:
@@ -162,10 +188,18 @@ class GarminStagingWorker:
162
188
  "golf_user_stats": ("get_golf_user_stats", "golf_user_stat"),
163
189
  "golf_club_stats": ("get_golf_club_stats", "golf_club_stat"),
164
190
  }
165
- for endpoint, (method, entity_type) in calls.items():
191
+ if self.progress is not None:
192
+ self.progress.set_stage("singletons")
193
+ self.progress.advance(total=len(calls))
194
+ for index, (endpoint, (method, entity_type)) in enumerate(calls.items(), start=1):
195
+ self._check_interrupted()
196
+ if self.progress is not None:
197
+ self.progress.advance(step=endpoint)
166
198
  payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)())
167
199
  if payload is not None:
168
200
  self._entity(endpoint, entity_type, payload, raw_hash)
201
+ if self.progress is not None:
202
+ self.progress.advance(completed=index)
169
203
 
170
204
  def _daily(self, from_date: date, to_date: date) -> None:
171
205
  calls = {
@@ -183,13 +217,26 @@ class GarminStagingWorker:
183
217
  "nutrition_food_log": ("get_nutrition_daily_food_log", "nutrition_log"), "nutrition_meals": ("get_nutrition_daily_meals", "nutrition_log"),
184
218
  "nutrition_settings": ("get_nutrition_daily_settings", "nutrition_setting"),
185
219
  }
220
+ total_days = (to_date - from_date).days + 1
221
+ if self.progress is not None:
222
+ self.progress.set_stage("daily")
223
+ self.progress.advance(total=total_days)
186
224
  current = from_date
225
+ completed_days = 0
187
226
  while current <= to_date:
227
+ self._check_interrupted()
188
228
  day = current.isoformat()
229
+ if self.progress is not None:
230
+ self.progress.advance(step=day)
189
231
  for endpoint, (method, entity_type) in calls.items():
232
+ if self.progress is not None:
233
+ self.progress.advance(step=f"{day} {endpoint}")
190
234
  payload, raw_hash = self._capture(endpoint, lambda method=method, day=day: getattr(self.api, method)(day), day, {"date": day})
191
235
  if payload is not None:
192
236
  self._entity(endpoint, entity_type, payload, raw_hash, day)
237
+ completed_days += 1
238
+ if self.progress is not None:
239
+ self.progress.advance(completed=completed_days)
193
240
  current += timedelta(days=1)
194
241
 
195
242
  def _range(self, from_date: date, to_date: date) -> None:
@@ -198,7 +245,10 @@ class GarminStagingWorker:
198
245
  "blood_pressure": ("get_blood_pressure", "blood_pressure"), "weigh_ins": ("get_weigh_ins", "body_composition"),
199
246
  "progress_summary": ("get_progress_summary_between_dates", "progress_summary"), "menstrual_calendar": ("get_menstrual_calendar_data", "menstrual"),
200
247
  }
201
- for endpoint, (method, entity_type) in calls.items():
248
+ if self.progress is not None:
249
+ self.progress.set_stage("range")
250
+ self.progress.advance(total=len(calls))
251
+ for index, (endpoint, (method, entity_type)) in enumerate(calls.items(), start=1):
202
252
  # Garmin rejects broad body-battery and menstrual-calendar ranges.
203
253
  # Keep every request inside the provider's 92-day limit.
204
254
  window_start = from_date
@@ -206,23 +256,36 @@ class GarminStagingWorker:
206
256
  # ranges. Keep it deliberately small; the captures are
207
257
  # content-addressed so overlapping retry windows are harmless.
208
258
  window_days = 7 if endpoint == "body_battery" else (90 if endpoint == "menstrual_calendar" else (to_date - from_date).days + 1)
259
+ if self.progress is not None:
260
+ self.progress.advance(step=endpoint)
209
261
  while window_start <= to_date:
262
+ self._check_interrupted()
210
263
  window_end = min(to_date, window_start + timedelta(days=window_days - 1))
211
264
  start, end = window_start.isoformat(), window_end.isoformat()
212
265
  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
266
  if payload is not None:
214
267
  self._entity(endpoint, entity_type, payload, raw_hash, fallback_scope=start)
215
268
  window_start = window_end + timedelta(days=1)
269
+ if self.progress is not None:
270
+ self.progress.advance(completed=index)
216
271
 
217
272
  def _cycling_ftp_history(self, from_date: date, to_date: date) -> None:
218
273
  """Stage daily cycling FTP setting history from Garmin's range endpoint."""
219
274
  endpoint = "functional_threshold_power_range"
220
275
  window_start = from_date
276
+ total_windows = (to_date - from_date).days // 89 + 1
277
+ if self.progress is not None:
278
+ self.progress.set_stage("ftp_history")
279
+ self.progress.advance(total=total_windows)
280
+ completed_windows = 0
221
281
  while window_start <= to_date:
282
+ self._check_interrupted()
222
283
  # This private Connect endpoint is not documented with a maximum
223
284
  # range. Keep requests aligned with other conservative range calls.
224
285
  window_end = min(to_date, window_start + timedelta(days=89))
225
286
  start, end = window_start.isoformat(), window_end.isoformat()
287
+ if self.progress is not None:
288
+ self.progress.advance(step=start)
226
289
  payload, raw_hash = self._capture(
227
290
  endpoint,
228
291
  lambda start=start, end=end: self.api.get_functional_threshold_power_range(
@@ -251,14 +314,25 @@ class GarminStagingWorker:
251
314
  raw_hash,
252
315
  occurred_on=occurred_on,
253
316
  )
317
+ completed_windows += 1
318
+ if self.progress is not None:
319
+ self.progress.advance(completed=completed_windows)
254
320
  window_start = window_end + timedelta(days=1)
255
321
 
256
322
  def _max_metrics_history(self, from_date: date, to_date: date) -> None:
257
323
  """Stage range max-metric responses by their actual calendar date."""
258
324
  window_start = from_date
325
+ total_windows = (to_date - from_date).days // 89 + 1
326
+ if self.progress is not None:
327
+ self.progress.set_stage("max_metrics")
328
+ self.progress.advance(total=total_windows)
329
+ completed_windows = 0
259
330
  while window_start <= to_date:
331
+ self._check_interrupted()
260
332
  window_end = min(to_date, window_start + timedelta(days=89))
261
333
  start, end = window_start.isoformat(), window_end.isoformat()
334
+ if self.progress is not None:
335
+ self.progress.advance(step=start)
262
336
  payload, raw_hash = self._capture(
263
337
  "max_metrics_range",
264
338
  lambda start=start, end=end: self.api.get_max_metrics_range(start, end),
@@ -286,14 +360,25 @@ class GarminStagingWorker:
286
360
  for item in as_items(fallback):
287
361
  self.writer.source_entity("max_metric", f"max_metrics:{day}", item, fallback_hash, occurred_on=day)
288
362
  current += timedelta(days=1)
363
+ completed_windows += 1
364
+ if self.progress is not None:
365
+ self.progress.advance(completed=completed_windows)
289
366
  window_start = window_end + timedelta(days=1)
290
367
 
291
368
  def _hrv_history(self, from_date: date, to_date: date) -> None:
292
369
  """Stage the PR-402 HRV range API, retaining daily fallback coverage."""
293
370
  window_start = from_date
371
+ total_windows = (to_date - from_date).days // 89 + 1
372
+ if self.progress is not None:
373
+ self.progress.set_stage("hrv_history")
374
+ self.progress.advance(total=total_windows)
375
+ completed_windows = 0
294
376
  while window_start <= to_date:
377
+ self._check_interrupted()
295
378
  window_end = min(to_date, window_start + timedelta(days=89))
296
379
  start, end = window_start.isoformat(), window_end.isoformat()
380
+ if self.progress is not None:
381
+ self.progress.advance(step=start)
297
382
  payload, raw_hash = self._capture(
298
383
  "hrv_range",
299
384
  lambda start=start, end=end: self.api.get_hrv_data_range(start, end),
@@ -315,14 +400,25 @@ class GarminStagingWorker:
315
400
  if fallback is not None:
316
401
  self.writer.source_entity("daily_health", f"hrv:{day}", as_dict(fallback), fallback_hash, occurred_on=day)
317
402
  current += timedelta(days=1)
403
+ completed_windows += 1
404
+ if self.progress is not None:
405
+ self.progress.advance(completed=completed_windows)
318
406
  window_start = window_end + timedelta(days=1)
319
407
 
320
408
  def _score_history(self, from_date: date, to_date: date) -> None:
321
409
  """Stage daily-capable endurance-adjacent metrics from range endpoints."""
322
410
  window_start = from_date
411
+ total_windows = (to_date - from_date).days // 89 + 1
412
+ if self.progress is not None:
413
+ self.progress.set_stage("scores")
414
+ self.progress.advance(total=total_windows)
415
+ completed_windows = 0
323
416
  while window_start <= to_date:
417
+ self._check_interrupted()
324
418
  window_end = min(to_date, window_start + timedelta(days=89))
325
419
  start, end = window_start.isoformat(), window_end.isoformat()
420
+ if self.progress is not None:
421
+ self.progress.advance(step=start)
326
422
  for endpoint, method, entity_type in (
327
423
  ("hill_score", lambda: self.api.get_hill_score(start, end), "hill_score"),
328
424
  ("running_tolerance", lambda: self.api.get_running_tolerance(start, end, aggregation="daily"), "running_tolerance"),
@@ -333,33 +429,56 @@ class GarminStagingWorker:
333
429
  item_day = payload_date(item)
334
430
  if item_day:
335
431
  self.writer.source_entity(entity_type, f"{endpoint}:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
432
+ completed_windows += 1
433
+ if self.progress is not None:
434
+ self.progress.advance(completed=completed_windows)
336
435
  window_start = window_end + timedelta(days=1)
337
436
 
338
437
  # Garmin limits daily race-prediction windows to one year.
339
438
  window_start = from_date
439
+ total_windows = (to_date - from_date).days // 365 + 1
440
+ if self.progress is not None:
441
+ self.progress.advance(total=total_windows)
442
+ completed_windows = 0
340
443
  while window_start <= to_date:
444
+ self._check_interrupted()
341
445
  window_end = min(to_date, window_start + timedelta(days=365))
342
446
  start, end = window_start.isoformat(), window_end.isoformat()
447
+ if self.progress is not None:
448
+ self.progress.advance(step=start)
343
449
  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
450
  if payload is not None:
345
451
  for index, item in enumerate(as_items(payload)):
346
452
  item_day = payload_date(item) or start
347
453
  self.writer.source_entity("race_prediction", f"race_prediction:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
454
+ completed_windows += 1
455
+ if self.progress is not None:
456
+ self.progress.advance(completed=completed_windows)
348
457
  window_start = window_end + timedelta(days=1)
349
458
 
350
459
  def _activities(self, from_date: date, to_date: date) -> None:
351
460
  start, end = from_date.isoformat(), to_date.isoformat()
461
+ if self.progress is not None:
462
+ self.progress.set_stage("activities")
352
463
  payload, raw_hash = self._capture("activities", lambda: self.api.get_activities_by_date(start, end), None, {"from": start, "to": end})
353
464
  if payload is None:
354
465
  return
355
466
  self._entity("activities", "activity", payload, raw_hash)
356
- for summary in as_items(payload):
467
+ summaries = as_items(payload)
468
+ if self.progress is not None:
469
+ self.progress.advance(total=len(summaries))
470
+ for index, summary in enumerate(summaries, start=1):
471
+ self._check_interrupted()
357
472
  activity_id = remote_id(summary, "unknown")
473
+ if self.progress is not None:
474
+ self.progress.advance(step=activity_id)
358
475
  summary_hash = hashlib.sha256(json.dumps(summary, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
359
476
  details_fetched = self.known_activity_hashes.get(activity_id) != summary_hash
360
477
  if details_fetched:
361
478
  self._activity_details(activity_id, summary)
362
479
  self.writer.activity_sync_state(activity_id, summary_hash, details_fetched)
480
+ if self.progress is not None:
481
+ self.progress.advance(completed=index)
363
482
 
364
483
  def _multisport_children(self, parent_activity_id: str, activity: dict[str, Any]) -> None:
365
484
  if not activity.get("isMultiSportParent"):
@@ -444,7 +563,15 @@ class GarminStagingWorker:
444
563
  "workouts": ("get_workouts", (), "workout"), "scheduled_workouts": ("get_scheduled_workouts", (today.year, today.month), "scheduled_workout"),
445
564
  "goals": ("get_goals", (), "goal"), "golf_summary": ("get_golf_summary", (), "golf_scorecard"),
446
565
  }
447
- for endpoint, (method, arguments, entity_type) in calls.items():
566
+ if self.progress is not None:
567
+ self.progress.set_stage("collections")
568
+ self.progress.advance(total=len(calls))
569
+ for index, (endpoint, (method, arguments, entity_type)) in enumerate(calls.items(), start=1):
570
+ self._check_interrupted()
571
+ if self.progress is not None:
572
+ self.progress.advance(step=endpoint)
448
573
  payload, raw_hash = self._capture(endpoint, lambda method=method, arguments=arguments: getattr(self.api, method)(*arguments))
449
574
  if payload is not None:
450
575
  self._entity(endpoint, entity_type, payload, raw_hash)
576
+ if self.progress is not None:
577
+ self.progress.advance(completed=index)