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.
- package/README.md +28 -82
- package/config.example.json +25 -0
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +49 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +48 -0
- package/dist/core/runtime/catalog.js +17 -7
- package/dist/core/runtime/configuration.d.ts +3 -1
- package/dist/core/runtime/configuration.js +5 -1
- package/dist/core/runtime/opencode-go.d.ts +58 -0
- package/dist/core/runtime/opencode-go.js +246 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/detached-sync.d.ts +50 -0
- package/dist/elt/application/detached-sync.js +99 -0
- package/dist/elt/application/management.d.ts +15 -0
- package/dist/elt/application/management.js +435 -19
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +5 -4
- package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
- package/dist/elt/ingestion/providers/intervals.js +37 -2
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +18 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/normalization/normalizers.js +111 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +184 -6
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +200 -0
- package/dist/interfaces/cli/commands.js +116 -1
- package/dist/interfaces/http/generation-status.d.ts +19 -0
- package/dist/interfaces/http/generation-status.js +55 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +184 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +50 -13
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +4 -1
- package/docs/architecture.md +11 -1
- package/docs/configuration.md +243 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +263 -0
- package/docs/deployment/local-mcp.md +329 -0
- package/docs/development.md +337 -0
- package/docs/distribution.md +58 -0
- package/docs/llm-providers.md +249 -6
- package/docs/mcp-activity-retrieval.md +37 -30
- package/package.json +5 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +43 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/registry.py +1 -0
- package/python/catence/providers/garmin/streams.py +38 -0
- package/python/catence/providers/garmin/swim_backfill.py +83 -0
- package/python/catence/providers/garmin/worker.py +372 -11
- package/scripts/discover-opencode-go.mjs +102 -0
- package/scripts/discover-opencode-zen.mjs +218 -0
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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"),
|
|
747
|
+
"workouts": ("get_workouts", (), "workout"),
|
|
445
748
|
"goals": ("get_goals", (), "goal"), "golf_summary": ("get_golf_summary", (), "golf_scorecard"),
|
|
446
749
|
}
|
|
447
|
-
|
|
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
|