catence 0.2.0-beta.16 → 0.2.0-beta.17

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.
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.16";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.17";
7
7
  export declare const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export type CatenceRuntimeCapabilities = {
9
9
  mcp: true;
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.16';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.17';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -42,27 +42,65 @@ export async function setAthleteSecret(paths, provider, field, value) {
42
42
  await rename(temporary, destination);
43
43
  await chmod(destination, 0o600);
44
44
  }
45
+ function isTruthyEnvValue(value) {
46
+ if (!value)
47
+ return false;
48
+ return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
49
+ }
50
+ function isGenericEnvFallbackAllowed(base) {
51
+ const direct = base.CATENCE_ALLOW_ENV_SECRETS ?? base.CATENCE_SECRETS_FROM_ENV ?? process.env.CATENCE_ALLOW_ENV_SECRETS ?? process.env.CATENCE_SECRETS_FROM_ENV;
52
+ return isTruthyEnvValue(direct);
53
+ }
54
+ function athleteEnvPrefix(paths) {
55
+ const athleteId = path.basename(path.dirname(paths.secrets));
56
+ const normalized = athleteId.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
57
+ return `CATENCE_ATHLETE_${normalized}_`;
58
+ }
59
+ function resolveWithEnvFallback(fileValue, envName, perAthletePrefix, base, allowGeneric) {
60
+ if (fileValue)
61
+ return fileValue;
62
+ const perAthleteName = `${perAthletePrefix}${envName}`;
63
+ const perAthleteValue = base[perAthleteName] ?? process.env[perAthleteName];
64
+ if (perAthleteValue)
65
+ return perAthleteValue;
66
+ if (!allowGeneric)
67
+ return undefined;
68
+ const genericValue = base[envName] ?? process.env[envName];
69
+ if (genericValue)
70
+ return genericValue;
71
+ return undefined;
72
+ }
45
73
  /** Build only the provider environment needed by a selected athlete worker. */
46
74
  export async function athleteProviderEnvironment(paths, base = process.env) {
47
75
  const secrets = await readAthleteSecrets(paths);
48
76
  const environment = { ...base };
77
+ const allowGenericEnv = isGenericEnvFallbackAllowed(base);
78
+ const perAthletePrefix = athleteEnvPrefix(paths);
49
79
  // Never let credentials inherited by the shared process leak into an
50
- // athlete store that has not explicitly configured that provider.
80
+ // athlete store that has not explicitly configured that provider, unless
81
+ // the operator has opted in via CATENCE_ALLOW_ENV_SECRETS or is using a
82
+ // per-athlete scoped variable (CATENCE_ATHLETE_<ID>_GARMIN_EMAIL etc.).
51
83
  for (const name of ['GARMIN_EMAIL', 'GARMIN_PASSWORD', 'INTERVALS_API_KEY', 'INTERVALS_ATHLETE_ID', 'STRAVA_CLIENT_ID', 'STRAVA_CLIENT_SECRET']) {
52
84
  delete environment[name];
53
85
  }
54
- if (secrets.garmin?.email)
55
- environment.GARMIN_EMAIL = secrets.garmin.email;
56
- if (secrets.garmin?.password)
57
- environment.GARMIN_PASSWORD = secrets.garmin.password;
58
- if (secrets.intervals?.apiKey)
59
- environment.INTERVALS_API_KEY = secrets.intervals.apiKey;
60
- if (secrets.intervals?.athleteId)
61
- environment.INTERVALS_ATHLETE_ID = secrets.intervals.athleteId;
62
- if (secrets.strava?.clientId)
63
- environment.STRAVA_CLIENT_ID = secrets.strava.clientId;
64
- if (secrets.strava?.clientSecret)
65
- environment.STRAVA_CLIENT_SECRET = secrets.strava.clientSecret;
86
+ const garminEmail = resolveWithEnvFallback(secrets.garmin?.email, 'GARMIN_EMAIL', perAthletePrefix, base, allowGenericEnv);
87
+ const garminPassword = resolveWithEnvFallback(secrets.garmin?.password, 'GARMIN_PASSWORD', perAthletePrefix, base, allowGenericEnv);
88
+ const intervalsApiKey = resolveWithEnvFallback(secrets.intervals?.apiKey, 'INTERVALS_API_KEY', perAthletePrefix, base, allowGenericEnv);
89
+ const intervalsAthleteId = resolveWithEnvFallback(secrets.intervals?.athleteId, 'INTERVALS_ATHLETE_ID', perAthletePrefix, base, allowGenericEnv);
90
+ const stravaClientId = resolveWithEnvFallback(secrets.strava?.clientId, 'STRAVA_CLIENT_ID', perAthletePrefix, base, allowGenericEnv);
91
+ const stravaClientSecret = resolveWithEnvFallback(secrets.strava?.clientSecret, 'STRAVA_CLIENT_SECRET', perAthletePrefix, base, allowGenericEnv);
92
+ if (garminEmail)
93
+ environment.GARMIN_EMAIL = garminEmail;
94
+ if (garminPassword)
95
+ environment.GARMIN_PASSWORD = garminPassword;
96
+ if (intervalsApiKey)
97
+ environment.INTERVALS_API_KEY = intervalsApiKey;
98
+ if (intervalsAthleteId)
99
+ environment.INTERVALS_ATHLETE_ID = intervalsAthleteId;
100
+ if (stravaClientId)
101
+ environment.STRAVA_CLIENT_ID = stravaClientId;
102
+ if (stravaClientSecret)
103
+ environment.STRAVA_CLIENT_SECRET = stravaClientSecret;
66
104
  return environment;
67
105
  }
68
106
  //# sourceMappingURL=secrets.js.map
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
- import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
5
  import { createServer } from 'node:http';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
@@ -28,6 +28,86 @@ function baseProgress(runId, provider, stage) {
28
28
  estimatedRemainingSeconds: null, heartbeatAt: new Date().toISOString(),
29
29
  };
30
30
  }
31
+ /**
32
+ * Run the standalone swim-length backfill without progress streaming.
33
+ * The backfill reads every archived Garmin FIT (already on disk) and emits
34
+ * source_entity rows for any swim that actually contains length messages;
35
+ * importing them is idempotent, so re-parsing already-imported swims is
36
+ * harmless.
37
+ */
38
+ function runSwimBackfillProcess(arguments_, environment, log) {
39
+ return new Promise((resolve) => {
40
+ const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
41
+ const stdout = [];
42
+ const stderr = [];
43
+ child.stdout.on('data', (chunk) => stdout.push(chunk.toString('utf8')));
44
+ child.stderr.on('data', (chunk) => stderr.push(chunk.toString('utf8')));
45
+ child.on('error', (error) => {
46
+ log.error('Garmin swim-backfill process could not be started', { error: error.message });
47
+ resolve({ code: 1, signal: null });
48
+ });
49
+ child.on('close', (code, signal) => {
50
+ const out = stdout.join('').trim();
51
+ const err = stderr.join('').trim();
52
+ if (out)
53
+ log.info(`[garmin swim_backfill] ${out}`);
54
+ if (err)
55
+ log.error(`[garmin swim_backfill] ${err}`);
56
+ resolve({ code, signal });
57
+ });
58
+ });
59
+ }
60
+ async function backfillGarminSwimLengths(database, paths, parentRunId, log) {
61
+ const output = path.join(paths.staging, 'garmin', `${parentRunId}.swim_lengths.jsonl`);
62
+ await mkdir(path.dirname(output), { recursive: true });
63
+ // Fresh file for deterministic counting; a stale file would make
64
+ // line counting ambiguous.
65
+ try {
66
+ const { rm } = await import('node:fs/promises');
67
+ await rm(output, { force: true });
68
+ }
69
+ catch {
70
+ // Best-effort cleanup.
71
+ }
72
+ // The swim list is queried here and handed to the Python worker as a file:
73
+ // the child must never open DuckDB itself because the parent holds the
74
+ // write lock for the whole run.
75
+ const activitiesFile = path.join(paths.staging, 'garmin', `${parentRunId}.swim_activities.json`);
76
+ await writeFile(activitiesFile, JSON.stringify(await database.garminSwimActivitySources()));
77
+ const arguments_ = [
78
+ 'run',
79
+ 'python',
80
+ '-m',
81
+ 'python.catence.providers.garmin.swim_backfill',
82
+ '--data-dir',
83
+ paths.root,
84
+ '--output',
85
+ output,
86
+ '--activities',
87
+ activitiesFile,
88
+ ];
89
+ const environment = {
90
+ cwd: packageRoot(),
91
+ env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
92
+ };
93
+ const outcome = await runSwimBackfillProcess(arguments_, environment, log);
94
+ if (outcome.code !== 0) {
95
+ throw new Error(`Garmin swim_backfill exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
96
+ }
97
+ if (!existsSync(output)) {
98
+ log.info('Garmin swim backfill produced no output file', { parentRunId });
99
+ return;
100
+ }
101
+ const content = await readFile(output, 'utf8');
102
+ const lines = content.split('\n').filter((line) => line.trim().length > 0);
103
+ if (lines.length === 0) {
104
+ log.info('Garmin swim backfill produced no staged lengths', { parentRunId });
105
+ return;
106
+ }
107
+ const backfillRunId = randomUUID();
108
+ const imported = await importJsonl(database, backfillRunId, output, log);
109
+ log.info('Imported swim lengths from automatic backfill', { parentRunId, backfillRunId, staged: lines.length, imported });
110
+ }
31
111
  /**
32
112
  * Run the Garmin staging worker with streamed stdout/stderr so progress and
33
113
  * errors surface in real time instead of buffering until the child exits.
@@ -198,6 +278,16 @@ async function syncProvider(database, paths, provider, options, log) {
198
278
  arguments_.push('--skip-activities');
199
279
  if (options.backfill && !options.refreshActivities)
200
280
  arguments_.push('--historical-only');
281
+ // Data-completeness catch-up: stores synced before lactate-threshold
282
+ // history capture only hold the latest singleton point. When no
283
+ // per-day LT observations exist yet, widen the worker's LT window to
284
+ // the athlete's first activity so the history backfills once; the
285
+ // condition self-clears after the first successful import.
286
+ if (!(await database.hasGarminLactateThresholdHistory())) {
287
+ const earliest = await database.earliestActivityStartDate('garmin');
288
+ if (earliest)
289
+ arguments_.push('--lt-history-from', earliest);
290
+ }
201
291
  let interruptChild = null;
202
292
  await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
203
293
  const outcome = await runGarminWorker(runId, arguments_, {
@@ -212,6 +302,17 @@ async function syncProvider(database, paths, provider, options, log) {
212
302
  if (!existsSync(output))
213
303
  throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
214
304
  await importJsonl(database, runId, output, log);
305
+ if (provider === 'garmin') {
306
+ try {
307
+ await backfillGarminSwimLengths(database, paths, runId, log);
308
+ }
309
+ catch (error) {
310
+ log.warn('Garmin swim-length backfill after sync failed', {
311
+ runId,
312
+ error: error instanceof Error ? error.message : String(error),
313
+ });
314
+ }
315
+ }
215
316
  }
216
317
  }
217
318
  }
@@ -56,6 +56,12 @@ export declare class CatenceDatabase implements WriteDataStore {
56
56
  activityNeedsDetailSync(provider: Provider, remoteActivityId: string, summaryHash: string): Promise<boolean>;
57
57
  recordActivitySyncState(provider: Provider, remoteActivityId: string, summaryHash: string, runId: string, detailsFetched: boolean): Promise<void>;
58
58
  knownActivitySummaryHashes(provider: Provider): Promise<Record<string, string>>;
59
+ hasGarminLactateThresholdHistory(): Promise<boolean>;
60
+ earliestActivityStartDate(provider: Provider): Promise<string | null>;
61
+ garminSwimActivitySources(): Promise<Array<{
62
+ remote_activity_id: string;
63
+ started_on: string | null;
64
+ }>>;
59
65
  private latestSourceDate;
60
66
  private upsertCursor;
61
67
  }
@@ -271,6 +271,24 @@ export class CatenceDatabase {
271
271
  const rows = await this.rows('SELECT remote_activity_id, summary_hash FROM activity_sync_state WHERE provider = $provider', { provider });
272
272
  return Object.fromEntries(rows.map((row) => [row.remote_activity_id, row.summary_hash]));
273
273
  }
274
+ async hasGarminLactateThresholdHistory() {
275
+ const rows = await this.rows("SELECT count(*) AS n FROM training_metric_observations WHERE observation_id LIKE 'garmin:lactate_threshold:daily:%'");
276
+ return (rows[0]?.n ?? 0) > 0;
277
+ }
278
+ async earliestActivityStartDate(provider) {
279
+ const rows = await this.rows(`SELECT min(cast(started_at_utc AS DATE)) AS earliest FROM activities activity
280
+ JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider`, { provider });
281
+ return toIsoDate(rows[0]?.earliest ?? null);
282
+ }
283
+ async garminSwimActivitySources() {
284
+ const sports = ['lap_swimming', 'swimming', 'open_water_swimming', 'openwaterswimming'];
285
+ return this.rows(`SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_on
286
+ FROM activity_sources AS source
287
+ JOIN activities AS activity USING (activity_id)
288
+ WHERE source.provider = 'garmin'
289
+ AND lower(coalesce(activity.sport, '')) IN (${sports.map((sport) => `'${sport}'`).join(', ')})
290
+ ORDER BY activity.started_at_utc`);
291
+ }
274
292
  async latestSourceDate(provider, cursorName) {
275
293
  const observations = cursorName === 'daily'
276
294
  ? `SELECT max(metric_date) AS observed_date FROM daily_metrics WHERE provider = $provider
@@ -185,6 +185,22 @@ into the provider worker environment, and inherited `GARMIN_*`/`INTERVALS_*`/
185
185
  `STRAVA_*` variables are stripped first so an athlete without a provider cannot
186
186
  inherit its credentials.
187
187
 
188
+ For local and Docker development you can also keep credentials in env without
189
+ `secret set` — see the two fallback modes below. File values always win over
190
+ env, so a `providers.json` entry overrides any env fallback for that field.
191
+
192
+ ### Provider credentials via env (dev fallback)
193
+
194
+ Two opt-in env fallbacks are checked **only when a `providers.json` field is
195
+ missing** — file values always win. See `src/core/runtime/secrets.ts:51`.
196
+
197
+ | Mode | Variables | When allowed | Example for athlete `alex` |
198
+ |------|-----------|--------------|----------------------------|
199
+ | **Per-athlete scoped** (recommended, multi-athlete safe) | `CATENCE_ATHLETE_<ID>_<PROVIDER_VAR>` — prefix is `CATENCE_ATHLETE_` + upper-cased athlete id with `-` → `_` + `_` | **Always** — checked even when the generic flag is off. Only maps to that one athlete. | `CATENCE_ATHLETE_ALEX_GARMIN_EMAIL=alex@example.com` `CATENCE_ATHLETE_ALEX_INTERVALS_API_KEY=…` |
200
+ | **Generic** (single-athlete dev, opt-in) | `GARMIN_EMAIL`, `GARMIN_PASSWORD`, `INTERVALS_API_KEY`, `INTERVALS_ATHLETE_ID`, `STRAVA_CLIENT_ID`, `STRAVA_CLIENT_SECRET` | Only when `CATENCE_ALLOW_ENV_SECRETS` (or alias `CATENCE_SECRETS_FROM_ENV`) is `1`/`true`/`yes`/`on` in the worker's env. Without the flag, bare provider env vars are stripped. | `CATENCE_ALLOW_ENV_SECRETS=1` + `GARMIN_EMAIL=…` |
201
+
202
+ Resolution order per field: `providers.json` → `CATENCE_ATHLETE_<ID>_<VAR>` → (if flag set) `VAR`. Set `CATENCE_ALLOW_ENV_SECRETS=1` in `.env.dev` / `docker-compose.dev.yml` for single-athlete Docker dev, or prefer per-athlete vars for a shared catalog.
203
+
188
204
  ### Console auth and serving
189
205
 
190
206
  | Variable | Purpose |
@@ -0,0 +1,325 @@
1
+ # Catence development guide
2
+
3
+ This is the from-source counterpart to the three deployment guides. Use it to run, edit, and verify Catence without publishing a beta — locally on your machine and on a remote Docker host where you cannot live-debug until a new image is built.
4
+
5
+ Production installs use published artifacts (`npm install catence@beta` / `uv tool install catence-console` and `scripts/deploy-console.sh` → `console/Dockerfile.registry`). Everything below uses **your checkout directly**, so you can iterate on runtime, ELT, and Console code and test the real volume/config/secret paths before any release.
6
+
7
+ Related references: [`deployment/local-mcp.md`](deployment/local-mcp.md), [`deployment/local-console.md`](deployment/local-console.md), [`deployment/docker.md`](deployment/docker.md), [`configuration.md`](configuration.md), [`llm-providers.md`](llm-providers.md).
8
+
9
+ ## Which path when
10
+
11
+ | Goal | Path | Why |
12
+ |------|------|-----|
13
+ | Test ELT, MCP tools, queries, or TypeScript/Python logic | **Local from source** (`tsx` + `uv run`) | Fastest loop; no Docker, no image build |
14
+ | Test the Console UI, Chainlit auth, or the authenticated dashboard proxy | **Local Console from source** (`catence-console serve` against a live `CATENCE_HOME`) | Same stack as production but without a container |
15
+ | Test the Docker volume layout, `/data` seeding, `config.json` / `chat-history.sqlite3` persistence, or `deploy-console.sh`-adjacent wiring | **Docker source build** (`console/Dockerfile`) | One `docker build` from the checkout — no registry, no `CATENCE_NPM_VERSION` |
16
+ | Iterate inside a container without rebuilding (host file → container) | **Docker live bind-mount** (override entrypoint/command, keep `/data` on a volume) | Edit on host, restart container, re-test |
17
+
18
+ Most changes do not need Docker at all. Use Docker only when you are explicitly testing the container boundary (file permissions, volume seeding, Console networking, MCP bind).
19
+
20
+ ## Prerequisites
21
+
22
+ - Node.js 22+
23
+ - Python 3.12 ≤ version < 3.14, with [`uv`](https://docs.astral.sh/uv/)
24
+ - `git`, `curl`
25
+
26
+ ```sh
27
+ git clone https://github.com/rifusaki/catence.git
28
+ cd catence
29
+ npm ci
30
+ uv sync --project console
31
+ ```
32
+
33
+ No install step writes to a data home. The data home is created only by `catence-data setup` / `catence-data demo`.
34
+
35
+ ## Local from source (no Docker, no beta)
36
+
37
+ ### Choose a catalog home
38
+
39
+ The runtime, CLI, and Console all resolve the same home (`~/.catence` by default). Point it elsewhere with `CATENCE_HOME` or per-command `--home` so development never touches a personal catalog.
40
+
41
+ ```sh
42
+ # Isolated home for this checkout/session (recommended for development)
43
+ export CATENCE_HOME="$PWD/.catence-dev"
44
+ catence-data setup --athlete alex --label "Alex"
45
+ # or explicitly: catence-data --home "$PWD/.catence-dev" setup --athlete alex --label "Alex"
46
+ ```
47
+
48
+ The layout after `setup` is the same one Docker later mounts at `/data` — see [`configuration.md`](configuration.md) and [`deployment/local-console.md`](deployment/local-console.md#what-gets-stored-where):
49
+
50
+ ```text
51
+ $CATENCE_HOME/
52
+ catalog.json
53
+ config.json # Console model profiles — no secrets
54
+ console/chat-history.sqlite3
55
+ athletes/alex/
56
+ secrets/providers.json # mode 0600, written only via `secret set --value-stdin`
57
+ ```
58
+
59
+ Tear down a dev home with `rm -rf "$CATENCE_HOME"`; it is never committed (covered by `.gitignore`).
60
+
61
+ For a no-credentials smoke test, use the generated dataset instead:
62
+
63
+ ```sh
64
+ catence-data demo # creates/reuses ~/.catence-demo with athlete `demo`
65
+ npm run mcp -- demo # stdio MCP against the demo catalog
66
+ # The demo home is fixed to ~/.catence-demo; pass --home to move it.
67
+ ```
68
+
69
+ ### Provider credentials — file (production) or env (dev)
70
+
71
+ **Preferred (production):** credentials are stored per athlete in `secrets/providers.json` (mode 0600) — never in `config.json`. Same interface as [`deployment/local-mcp.md`](deployment/local-mcp.md#3-store-provider-credentials-stdin-only-never-shell-history):
72
+
73
+ ```sh
74
+ # Garmin
75
+ printf %s 'alex@example.com' | npm run catence-data -- --athlete alex secret set --provider garmin --field email --value-stdin
76
+ printf %s 'your-garmin-password' | npm run catence-data -- --athlete alex secret set --provider garmin --field password --value-stdin
77
+ # Intervals.icu
78
+ printf %s 'intervals-api-key' | npm run catence-data -- --athlete alex secret set --provider intervals --field apiKey --value-stdin
79
+ printf %s '12345' | npm run catence-data -- --athlete alex secret set --provider intervals --field athleteId --value-stdin
80
+ # Strava (create an API application at https://www.strava.com/settings/api)
81
+ printf %s 'strava-client-id' | npm run catence-data -- --athlete alex secret set --provider strava --field clientId --value-stdin
82
+ printf %s 'strava-client-secret' | npm run catence-data -- --athlete alex secret set --provider strava --field clientSecret --value-stdin
83
+ ```
84
+
85
+ File values always win. Without a file entry the worker's inherited `GARMIN_*` / `INTERVALS_*` / `STRAVA_*` are normally stripped so an unset provider cannot silently inherit another athlete's credentials.
86
+
87
+ **Easier for local/Docker dev (`src/core/runtime/secrets.ts:51`):** when a `providers.json` field is missing, the worker also checks env fallbacks:
88
+
89
+ ```sh
90
+ # Option A — per-athlete scoped (always allowed, multi-athlete safe, no flag)
91
+ export CATENCE_ATHLETE_ALEX_GARMIN_EMAIL='alex@example.com'
92
+ export CATENCE_ATHLETE_ALEX_GARMIN_PASSWORD='…'
93
+ export CATENCE_ATHLETE_ALEX_INTERVALS_API_KEY='…'
94
+ export CATENCE_ATHLETE_ALEX_INTERVALS_ATHLETE_ID='12345'
95
+ export CATENCE_ATHLETE_ALEX_STRAVA_CLIENT_ID='…'
96
+ export CATENCE_ATHLETE_ALEX_STRAVA_CLIENT_SECRET='…'
97
+ # Prefix is CATENCE_ATHLETE_<ID>_ where <ID> is the athlete id upper-cased with '-' → '_'.
98
+
99
+ # Option B — generic (single-athlete dev, opt-in via flag)
100
+ export CATENCE_ALLOW_ENV_SECRETS=1 # or CATENCE_SECRETS_FROM_ENV=1 — values 1/true/yes/on
101
+ export GARMIN_EMAIL='alex@example.com'
102
+ export GARMIN_PASSWORD='…'
103
+ export INTERVALS_API_KEY='…'
104
+ export INTERVALS_ATHLETE_ID='12345'
105
+ export STRAVA_CLIENT_ID='…'
106
+ export STRAVA_CLIENT_SECRET='…'
107
+ ```
108
+
109
+ Resolution per field: `providers.json` → `CATENCE_ATHLETE_<ID>_<VAR>` → (if `CATENCE_ALLOW_ENV_SECRETS` set) `VAR`. For Docker, put these in `.env.dev` and pass through `environment:` in `docker-compose.dev.yml` (or `--env-file .env.dev`); the `secret set --value-stdin` flow still works inside `docker compose run --entrypoint catence-data` for one-off seeding. See [`configuration.md`](configuration.md#provider-credentials-via-env-dev-fallback) and `.env.example`.
110
+
111
+ ### Model profiles (where model definitions live)
112
+
113
+ `config.json` in the data home holds **definitions only** — profile ids, LiteLLM model names, and the *names* of environment variables that hold keys/URLs (`apiKeyEnv`, `apiBaseEnv`, `apiVersionEnv`). The values stay in the process environment. See [`configuration.md`](configuration.md#consoleprofiles) and [`llm-providers.md`](llm-providers.md) for the schema.
114
+
115
+ For development, three options (all write to `config.json` in `CATENCE_HOME`):
116
+
117
+ ```sh
118
+ # 1) Start from the documented example
119
+ cp config.example.json "$CATENCE_HOME/config.json"
120
+ # then set the env var the profile names, e.g. OPENAI_API_KEY / OPENCODE_API_KEY
121
+
122
+ # 2) Let the Console wizard create one on first chat (replaces the whole `console` section)
123
+ # 3) Merge OpenCode Go / Zen profiles without clobbering existing ones
124
+ npm run discover:opencode-go -- --write "$CATENCE_HOME/config.json"
125
+ npm run discover:opencode-zen -- --write "$CATENCE_HOME/config.json" --free-only
126
+
127
+ # Verify
128
+ npm run console:doctor -- --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp
129
+ ```
130
+
131
+ Changing `config.json` is picked up on the next Console request — no repository file tracks deployment-specific models or keys. Keep `config.example.json` as the canonical checked-in example.
132
+
133
+ ### Running MCP, ELT, and tests without building `dist`
134
+
135
+ `package.json` exposes `tsx` dev scripts so edits are live without `npm run build`. Large edits should still pass `npm run check` (`tsc --noEmit` plus the runtime-boundary guard):
136
+
137
+ ```sh
138
+ npm run mcp -- --help
139
+ npm run mcp -- --home "$CATENCE_HOME" # stdio MCP (same wire as the packaged `catence` bin)
140
+ npm run catence-data -- --home "$CATENCE_HOME" status --athlete alex
141
+ npm run catence-data -- --home "$CATENCE_HOME" sync --athlete alex --provider garmin --from 2025-07-29
142
+ npm run catence-data -- --home "$CATENCE_HOME" build-retrieval-index --athlete alex
143
+
144
+ # Hot-reload loop for MCP iteration (restarts on src/ change)
145
+ npx tsx watch src/interfaces/mcp/main.ts -- --home "$CATENCE_HOME"
146
+
147
+ # Tests (DuckDB integration tests use a 15s per-test timeout)
148
+ npm test
149
+ npm run test:watch
150
+ npm run check
151
+ ```
152
+
153
+ `npm run mcp` and `npm run catence-data` are thin wrappers for `tsx src/interfaces/{mcp,cli}/main.ts`; the packaged `dist/` is only needed for `npm pack` / `prepublishOnly` validation (`npm run release:check`).
154
+
155
+ ### Running the Console locally against the dev catalog
156
+
157
+ The Console normally auto-starts a matching packaged runtime (`npx catence@<pinned>`). For development, point it at your live `tsx` runtime instead via `--mcp-url`:
158
+
159
+ ```sh
160
+ # Terminal 1 — live MCP/ELT runtime on loopback
161
+ npx tsx watch src/interfaces/mcp/main.ts -- serve --home "$CATENCE_HOME" --host 127.0.0.1 --port 8787
162
+
163
+ # Terminal 2 — Console using that runtime (no auto-started `npx` runtime)
164
+ export CATENCE_CONSOLE_USERNAME='coach'
165
+ export CATENCE_CONSOLE_PASSWORD_HASH="$(uv run --project console catence-console auth hash-password)"
166
+ export CHAINLIT_AUTH_SECRET="$(openssl rand -hex 32)"
167
+ export OPENAI_API_KEY='…' # or ANTHROPIC_API_KEY / OPENCODE_API_KEY …
168
+ npm run console -- serve --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp --ui-port 8000
169
+ # open http://127.0.0.1:8000
170
+ ```
171
+
172
+ `--mcp-url` is the feature that makes the live bind-mount loop below possible — the same flag works when the runtime lives inside a container (see next section).
173
+
174
+ ## Docker without publishing a beta
175
+
176
+ ### Why `scripts/deploy-console.sh` forces a publish
177
+
178
+ The production scaffold (`scripts/deploy-console.sh`) resolves `CATENCE_NPM_VERSION` / `CATENCE_CONSOLE_VERSION` from the **public registries** and bakes `console/Dockerfile.registry` — a three-line image that runs `npm install --global catence@${CATENCE_NPM_VERSION}` and `uv pip install catence-console==${CATENCE_CONSOLE_VERSION}`. Rebuilding that scaffold on a server cannot test local edits until they exist on npm/PyPI, which is the bottleneck this guide removes.
179
+
180
+ ### Source build — `console/Dockerfile` (reliable, still requires a rebuild)
181
+
182
+ `console/Dockerfile` is the from-source image: build context is the **repository root**, it runs `npm ci && npm run build && npm install --global .`, then `uv sync --project console`. Build it directly in any checkout, on any server with Docker:
183
+
184
+ ```sh
185
+ # From the repository root — tagged independently from the registry channel tags
186
+ docker build -f console/Dockerfile -t catence-console:dev .
187
+
188
+ # Run it the same way `docker.md` does, but with your local env
189
+ docker run --rm -p 127.0.0.1:8000:8000 -v catence-dev-data:/data \
190
+ -e CATENCE_CONSOLE_USERNAME='coach' \
191
+ -e CATENCE_CONSOLE_PASSWORD_HASH='replace-with-bcrypt-hash' \
192
+ -e CHAINLIT_AUTH_SECRET='replace-with-a-long-random-secret' \
193
+ -e OPENAI_API_KEY='…' \
194
+ catence-console:dev
195
+
196
+ # Or bind-mount a host data directory for easy inspection
197
+ mkdir -p /srv/catence-dev
198
+ docker run --rm -p 127.0.0.1:8000:8000 -v /srv/catence-dev:/data \
199
+ --env-file .env.dev \
200
+ catence-console:dev
201
+ ```
202
+
203
+ Seed and inspect the volume exactly as in [`deployment/docker.md`](deployment/docker.md):
204
+
205
+ ```sh
206
+ docker run --rm -v catence-dev-data:/data --entrypoint catence-data catence-console:dev setup --athlete alex --label "Alex" --home /data
207
+ docker run --rm -v catence-dev-data:/data --entrypoint sh catence-console:dev -c 'ls -R /data; cat /data/config.json'
208
+ ```
209
+
210
+ Rebuilding the image is the only step that reflects code changes under this path; the data volume and `config.json` are preserved across rebuilds.
211
+
212
+ ### Live loop — bind-mount the checkout into the container (no rebuild per edit)
213
+
214
+ When you need to edit on the server itself, mount the checkout into a dev container and have the entrypoint run the `tsx` watcher / `uv run` directly. The scaffold below is intentionally small — it keeps the **data volume** as the only state, so Console auth and `config.json` behave identically to production.
215
+
216
+ Minimal override compose (save as `docker-compose.dev.yml` alongside the checkout):
217
+
218
+ ```yaml
219
+ services:
220
+ console:
221
+ build:
222
+ context: .
223
+ dockerfile: console/Dockerfile
224
+ image: catence-console:dev
225
+ ports:
226
+ - "127.0.0.1:8000:8000"
227
+ - "127.0.0.1:8787:8787"
228
+ environment:
229
+ CATENCE_HOME: /data
230
+ CATENCE_CONSOLE_USERNAME: ${CATENCE_CONSOLE_USERNAME:-}
231
+ CATENCE_CONSOLE_PASSWORD_HASH: ${CATENCE_CONSOLE_PASSWORD_HASH:-}
232
+ CHAINLIT_AUTH_SECRET: ${CHAINLIT_AUTH_SECRET:-}
233
+ # Provider credentials via env (see "Provider credentials" above):
234
+ # Per-athlete scoped vars work without a flag; generic vars need CATENCE_ALLOW_ENV_SECRETS=1.
235
+ CATENCE_ALLOW_ENV_SECRETS: ${CATENCE_ALLOW_ENV_SECRETS:-}
236
+ GARMIN_EMAIL: ${GARMIN_EMAIL:-}
237
+ GARMIN_PASSWORD: ${GARMIN_PASSWORD:-}
238
+ INTERVALS_API_KEY: ${INTERVALS_API_KEY:-}
239
+ INTERVALS_ATHLETE_ID: ${INTERVALS_ATHLETE_ID:-}
240
+ STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
241
+ STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
242
+ # Per-athlete example (add your athlete id upper-cased, '-' → '_'):
243
+ # CATENCE_ATHLETE_ALEX_GARMIN_EMAIL: ${CATENCE_ATHLETE_ALEX_GARMIN_EMAIL:-}
244
+ OPENAI_API_KEY: ${OPENAI_API_KEY:-}
245
+ OPENAI_API_BASE: ${OPENAI_API_BASE:-}
246
+ ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
247
+ OPENCODE_API_KEY: ${OPENCODE_API_KEY:-}
248
+ OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-}
249
+ OPENCODE_GO_API_BASE: ${OPENCODE_GO_API_BASE:-}
250
+ OPENCODE_GO_MESSAGES_API_BASE: ${OPENCODE_GO_MESSAGES_API_BASE:-}
251
+ OPENCODE_ZEN_API_BASE: ${OPENCODE_ZEN_API_BASE:-}
252
+ OPENCODE_ZEN_RESPONSES_API_BASE: ${OPENCODE_ZEN_RESPONSES_API_BASE:-}
253
+ volumes:
254
+ - catence-dev-data:/data # keep Console data on the volume
255
+ - .:/app # live source
256
+ - /app/node_modules # keep container's installed deps
257
+ - /app/console/.venv # keep container's venv
258
+ - /app/dist # keep container build isolated (see .dockerignore)
259
+ - /app/.catence-dev # hide host dev home from container
260
+ - /app/.catence # hide host home from container
261
+ command: >-
262
+ sh -c "npm run build &&
263
+ uv run --project /app/console catence-console serve
264
+ --home /data --ui-host 0.0.0.0 --ui-port 8000
265
+ --mcp-host 0.0.0.0 --mcp-port 8787"
266
+ # for no-restart TS hot-reload, replace the command with:
267
+ # npx tsx watch src/interfaces/mcp/main.ts -- serve --home /data --host 0.0.0.0 --port 8787
268
+ # and run Console separately with --mcp-url
269
+
270
+ volumes:
271
+ catence-dev-data:
272
+ ```
273
+
274
+ ```sh
275
+ # .env.dev — never committed
276
+ cat > .env.dev <<'EOF'
277
+ CATENCE_CONSOLE_USERNAME=coach
278
+ CATENCE_CONSOLE_PASSWORD_HASH='replace-with-bcrypt-hash'
279
+ CHAINLIT_AUTH_SECRET='replace-with-long-random-string'
280
+ OPENAI_API_KEY='…'
281
+ # Provider creds via env (easier than secret set for dev) — either:
282
+ # Per-athlete (always, multi-athlete safe): CATENCE_ATHLETE_ALEX_GARMIN_EMAIL=…
283
+ # Generic (needs flag): CATENCE_ALLOW_ENV_SECRETS=1 + GARMIN_EMAIL=…
284
+ EOF
285
+
286
+ docker compose -f docker-compose.dev.yml --env-file .env.dev up --build
287
+ ```
288
+
289
+ Edits to `src/` and `console/catence_console/` on the host are visible inside; `docker compose restart console` picks up TypeScript changes after `npm run build`, while adding `npx tsx watch` to the command removes even that step. The `/data` volume is still the authority for `config.json`, `secrets/providers.json`, and `console/chat-history.sqlite3`, so the same `catence-data secret set --value-stdin --home /data` flow works via:
290
+
291
+ ```sh
292
+ docker compose -f docker-compose.dev.yml --env-file .env.dev exec console \
293
+ catence-data --home /data status --athlete alex
294
+ ```
295
+
296
+ An alternative that avoids running `npm` inside the mount loop is to keep the runtime on the host entirely: run `npx tsx watch src/interfaces/mcp/main.ts -- serve --home /srv/catence-dev ...` on the host and start the Console container with `--mcp-url http://host.docker.internal:8787/mcp` (or the host's Tailscale IP) and `--env-file .env.dev` — the same `--mcp-url` trick from the local Console section.
297
+
298
+ ## Where secrets and model definitions live (dev and Docker)
299
+
300
+ | What | Local dev | Docker dev/prod | Notes |
301
+ |------|-----------|-----------------|-------|
302
+ | Catalog + athlete stores | `CATENCE_HOME` (default `~/.catence`, dev: `$PWD/.catence-dev`) | `/data` (volume or bind mount) | Same on-disk layout; `CATENCE_HOME` controls it in both modes |
303
+ | Provider credentials (Garmin, Intervals, Strava) | `CATENCE_HOME/athletes/<id>/secrets/providers.json` (mode 0600) **or** env fallback when file field missing | `/data/athletes/<id>/secrets/providers.json` or same env fallback inside container | File wins; env fallback is `CATENCE_ATHLETE_<ID>_<VAR>` (always) or bare `GARMIN_*`/`INTERVALS_*`/`STRAVA_*` when `CATENCE_ALLOW_ENV_SECRETS=1` — see § Provider credentials above. Bare provider env vars are otherwise stripped before workers start |
304
+ | Model profiles + limits (`console.profiles`, `console.limits`) | `CATENCE_HOME/config.json` | `/data/config.json` (seeded from `config.example.json` on first deploy) | Versioned definitions + env-var names only. Credentials are read from the environment at serve time |
305
+ | API keys / base URLs | Process env (shell `export`, Docker `--env-file .env.dev`) | `.env` / `.env.dev` passed via `environment:` / `--env-file` | Named by the profile's `apiKeyEnv`/`apiBaseEnv`/`apiVersionEnv` (`OPENAI_API_KEY`, `OPENCODE_API_KEY`, `OPENCODE_GO_API_KEY`, …). `catence-console doctor` reports missing vars without printing values |
306
+ | Chat history | `CATENCE_HOME/console/chat-history.sqlite3` | `/data/console/chat-history.sqlite3` | Shared between runs when the volume is reused |
307
+
308
+ Recommended dev defaults:
309
+
310
+ - Keep a dedicated `CATENCE_HOME` (or local bind-mount path) per checkout or per test run; do not point development at a personal `~/.catence` catalog with real provider tokens.
311
+ - Commit no secrets: `.env*` (including `.env.dev`/`.env.beta`) and checkout-local `.catence-*` homes (which contain `athletes/<id>/secrets/providers.json`) are already ignored by `.gitignore`; the default `~/.catence` is outside the repo and naturally untracked.
312
+ - Treat `config.example.json` as the canonical model-definition template — copy its `console` section into the dev home's `config.json` and set only the one provider key you are testing.
313
+
314
+ ## Iteration notes
315
+
316
+ - Re-run `npm run check && npm test` before pushing; `vitest` integration tests create and migrate real DuckDB stores with a 15 s per-test timeout.
317
+ - `catence-data update` tracks the registry channel (`latest` vs `beta`); from source the update path is `git pull`, `npm ci && npm run build`, `uv sync --project console`.
318
+ - The deployed scaffold's `sync.sh` / `hash-password.sh` / `doctor.sh` helpers are generated by `scripts/deploy-console.sh`; the dev container does not generate them — use the `npm run` / `uv run` equivalents above, or run the same `catence-data --home /data` commands via `docker compose exec`.
319
+ - If the Console settings panel shows no Model dropdown, the data home's `config.json` has no `console.profiles` — re-copy `config.example.json` or re-run the discovery script, then restart the Console.
320
+
321
+ ## Troubleshooting
322
+
323
+ - **Doctor reports runtime unreachable** — the Console was started without `--mcp-url` and its auto-started runtime is on a different port. Start the runtime explicitly and pass `--mcp-url http://127.0.0.1:8787/mcp` to the Console, or use `uv run --project console catence-console doctor --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp`.
324
+ - **Docker `run` container has a separate loopback** — `docker compose run` cannot see a `docker compose exec` MCP server. Use `exec` for `doctor`/`sync` against the running container, or use `run` only for one-off `setup`/`secret set` with `--value-stdin`.
325
+ - **Refusing to initialize** — the data home contains unrelated files. The error lists them; only `config.json`, `console/`, and Chainlit's `.files/`, `.chainlit/`, `public/` are allowed alongside a catalog.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.16",
3
+ "version": "0.2.0-beta.17",
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": {
@@ -29,6 +29,12 @@ def parse_args() -> argparse.Namespace:
29
29
  parser.add_argument("--historical-only", action="store_true", help="Skip current account and collection endpoints.")
30
30
  parser.add_argument("--skip-daily", action="store_true", help="Do not fetch date-scoped daily data.")
31
31
  parser.add_argument("--skip-activities", action="store_true", help="Do not fetch date-scoped activities.")
32
+ parser.add_argument(
33
+ "--lt-history-from",
34
+ dest="lt_history_from",
35
+ type=date.fromisoformat,
36
+ help="Backfill lactate-threshold history from this date instead of the daily window.",
37
+ )
32
38
  return parser.parse_args()
33
39
 
34
40
 
@@ -72,6 +78,7 @@ def main() -> None:
72
78
  daily_to_date=args.daily_to_date,
73
79
  activity_to_date=args.activity_to_date,
74
80
  include_non_historical=not args.historical_only,
81
+ lactate_threshold_history_from=args.lt_history_from,
75
82
  )
76
83
  except WorkerInterrupted:
77
84
  reporter.finish("interrupted")
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import json
4
5
  from pathlib import Path
5
6
 
6
7
  from .staging import StagingWriter
@@ -13,14 +14,21 @@ def parse_args() -> argparse.Namespace:
13
14
  parser = argparse.ArgumentParser(description="Backfill swim lengths from already-archived Garmin activity FIT files.")
14
15
  parser.add_argument("--data-dir", required=True, type=Path)
15
16
  parser.add_argument("--output", required=True, type=Path)
17
+ parser.add_argument(
18
+ "--activities",
19
+ type=Path,
20
+ help=(
21
+ "JSON list of {remote_activity_id, started_on} rows produced by the TypeScript side. "
22
+ "Preferred over reading DuckDB because the parent process may hold the write lock."
23
+ ),
24
+ )
16
25
  return parser.parse_args()
17
26
 
18
27
 
19
- def main() -> None:
20
- args = parse_args()
21
- database_path = args.data_dir / "catence.duckdb"
22
- if not database_path.exists():
23
- raise SystemExit(f"Catalog database not found: {database_path}")
28
+ def load_swim_activities(activities_file: Path | None, database_path: Path) -> list[tuple[str, str | None]]:
29
+ if activities_file is not None:
30
+ payload = json.loads(activities_file.read_text())
31
+ return [(str(row["remote_activity_id"]), row.get("started_on")) for row in payload]
24
32
  import duckdb
25
33
 
26
34
  con = duckdb.connect(str(database_path), read_only=True)
@@ -35,6 +43,15 @@ def main() -> None:
35
43
  """.format(placeholders=", ".join(f"'{sport}'" for sport in SWIM_SPORTS)),
36
44
  ).fetchall()
37
45
  con.close()
46
+ return [(str(remote_id), started_at_utc) for remote_id, started_at_utc in rows]
47
+
48
+
49
+ def main() -> None:
50
+ args = parse_args()
51
+ database_path = args.data_dir / "catence.duckdb"
52
+ if args.activities is None and not database_path.exists():
53
+ raise SystemExit(f"Catalog database not found: {database_path}")
54
+ rows = load_swim_activities(args.activities, database_path)
38
55
 
39
56
  writer = StagingWriter(args.data_dir, args.output)
40
57
  staged = 0
@@ -44,6 +44,52 @@ def functional_threshold_power_items(value: Any) -> list[dict[str, Any]]:
44
44
  return []
45
45
 
46
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
+
47
93
  def ftp_history_date(payload: Mapping[str, Any]) -> str | None:
48
94
  for key in ("until", "calendarDate", "date", "updatedDate", "from"):
49
95
  value = payload.get(key)
@@ -111,6 +157,7 @@ class GarminStagingWorker:
111
157
  daily_to_date: date | None = None,
112
158
  activity_to_date: date | None = None,
113
159
  include_non_historical: bool = True,
160
+ lactate_threshold_history_from: date | None = None,
114
161
  ) -> None:
115
162
  assert_read_only_registry()
116
163
  end = to_date or date.today()
@@ -132,6 +179,14 @@ class GarminStagingWorker:
132
179
  self._hrv_history(daily_from_date, daily_end)
133
180
  self._check_interrupted()
134
181
  self._score_history(daily_from_date, daily_end)
182
+ # Lactate-threshold history uses the widest requested window so a
183
+ # single sync can catch stores up that were created before per-day
184
+ # LT capture existed; runs even when the daily window is skipped.
185
+ lt_windows = [window for window in (daily_from_date, lactate_threshold_history_from) if window is not None]
186
+ lt_start = min(lt_windows) if lt_windows else None
187
+ if lt_start is not None and lt_start <= daily_end:
188
+ self._check_interrupted()
189
+ self._lactate_threshold_history(lt_start, daily_end)
135
190
  if activity_from_date and activity_from_date <= activity_end:
136
191
  self._check_interrupted()
137
192
  self._activities(activity_from_date, activity_end)
@@ -319,6 +374,41 @@ class GarminStagingWorker:
319
374
  self.progress.advance(completed=completed_windows)
320
375
  window_start = window_end + timedelta(days=1)
321
376
 
377
+ def _lactate_threshold_history(self, from_date: date, to_date: date) -> None:
378
+ """Stage daily running lactate-threshold history from Garmin's range endpoints."""
379
+ window_start = from_date
380
+ total_windows = (to_date - from_date).days // 365 + 1
381
+ if self.progress is not None:
382
+ self.progress.set_stage("lt_history")
383
+ self.progress.advance(total=total_windows)
384
+ completed_windows = 0
385
+ while window_start <= to_date:
386
+ self._check_interrupted()
387
+ # Garmin rejects range windows longer than 366 days.
388
+ window_end = min(to_date, window_start + timedelta(days=365))
389
+ start, end = window_start.isoformat(), window_end.isoformat()
390
+ if self.progress is not None:
391
+ self.progress.advance(step=start)
392
+ payload, raw_hash = self._capture(
393
+ "lactate_threshold_range",
394
+ lambda start=start, end=end: self.api.get_lactate_threshold(latest=False, start_date=start, end_date=end, aggregation="daily"),
395
+ None,
396
+ {"from": start, "to": end, "aggregation": "daily"},
397
+ )
398
+ if payload is not None and raw_hash is not None:
399
+ for item_day, threshold in lactate_threshold_days(payload).items():
400
+ self.writer.source_entity(
401
+ "lactate_threshold",
402
+ f"daily:{item_day}",
403
+ lactate_threshold_payload(item_day, threshold),
404
+ raw_hash,
405
+ occurred_on=item_day,
406
+ )
407
+ completed_windows += 1
408
+ if self.progress is not None:
409
+ self.progress.advance(completed=completed_windows)
410
+ window_start = window_end + timedelta(days=1)
411
+
322
412
  def _max_metrics_history(self, from_date: date, to_date: date) -> None:
323
413
  """Stage range max-metric responses by their actual calendar date."""
324
414
  window_start = from_date
@@ -492,6 +582,49 @@ class GarminStagingWorker:
492
582
  if isinstance(payload, dict):
493
583
  self.writer.source_entity("activity", child_id, payload, raw_hash, parent_remote_id=parent_activity_id)
494
584
 
585
+ def _is_swim_activity(self, summary: dict[str, Any], activity: dict[str, Any] | None, details: dict[str, Any] | None) -> bool:
586
+ """Return True when any of the Garmin sport/type fields indicates a swim."""
587
+ candidates: list[str] = []
588
+ # Direct sport string fields on the list summary (including flat activityType string).
589
+ for key in ("sport", "sportType", "sport_type", "activityType", "activityTypeKey"):
590
+ value = summary.get(key)
591
+ if isinstance(value, str) and value:
592
+ candidates.append(value)
593
+ # Nested activityType shapes on the list summary.
594
+ for key in ("activityType", "activityTypeDTO"):
595
+ nested = summary.get(key)
596
+ if isinstance(nested, dict):
597
+ type_key = nested.get("typeKey") or nested.get("type_key") or nested.get("type") or nested.get("key")
598
+ if isinstance(type_key, str) and type_key:
599
+ candidates.append(type_key)
600
+ # Detailed payloads provide the authoritative typeKey.
601
+ for payload in (activity, details):
602
+ if not isinstance(payload, dict):
603
+ continue
604
+ for key in ("activityType", "activityTypeDTO"):
605
+ nested = payload.get(key)
606
+ if isinstance(nested, dict):
607
+ type_key = nested.get("typeKey")
608
+ if isinstance(type_key, str) and type_key:
609
+ candidates.append(type_key)
610
+ for key in ("sport", "sportType"):
611
+ value = payload.get(key)
612
+ if isinstance(value, str) and value:
613
+ candidates.append(value)
614
+ summary_dto = payload.get("summaryDTO")
615
+ if isinstance(summary_dto, dict):
616
+ for key in ("activityType", "activityTypeDTO"):
617
+ nested = summary_dto.get(key)
618
+ if isinstance(nested, dict):
619
+ type_key = nested.get("typeKey")
620
+ if isinstance(type_key, str) and type_key:
621
+ candidates.append(type_key)
622
+ for candidate in candidates:
623
+ lowered = candidate.lower()
624
+ if lowered == "lap_swimming" or "swim" in lowered:
625
+ return True
626
+ return False
627
+
495
628
  def _activity_details(self, activity_id: str, summary: dict[str, Any]) -> None:
496
629
  calls = {
497
630
  "activity": ("get_activity", "activity_detail"), "activity_details": ("get_activity_details", "activity_detail"),
@@ -501,17 +634,19 @@ class GarminStagingWorker:
501
634
  "activity_exercise_sets": ("get_activity_exercise_sets", "activity_exercise_set"), "activity_gear": ("get_activity_gear", "activity_gear"),
502
635
  }
503
636
  details: dict[str, Any] | None = None
637
+ activity_payload: dict[str, Any] | None = None
504
638
  for endpoint, (method, entity_type) in calls.items():
505
639
  payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)(activity_id), activity_id)
506
640
  if payload is not None:
507
641
  self._entity(endpoint, entity_type, payload, raw_hash, parent_remote_id=activity_id)
508
642
  if endpoint == "activity" and isinstance(payload, dict):
643
+ activity_payload = payload
509
644
  self._multisport_children(activity_id, payload)
510
645
  if endpoint == "activity_details" and isinstance(payload, dict):
511
646
  details = payload
512
- self._activity_files(activity_id, summary, details)
647
+ self._activity_files(activity_id, summary, activity_payload, details)
513
648
 
514
- def _activity_files(self, activity_id: str, summary: dict[str, Any], details: dict[str, Any] | None) -> None:
649
+ def _activity_files(self, activity_id: str, summary: dict[str, Any], activity: dict[str, Any] | None, details: dict[str, Any] | None) -> None:
515
650
  try:
516
651
  from garminconnect import Garmin
517
652
  formats = {
@@ -557,8 +692,7 @@ class GarminStagingWorker:
557
692
  occurred_on=start_date[:10],
558
693
  )
559
694
  if original_path and original_path.exists():
560
- sport = str(summary.get("sportType") or summary.get("sport") or "").lower()
561
- if sport == "lap_swimming" or "swim" in sport:
695
+ if self._is_swim_activity(summary, activity, details):
562
696
  lengths = fit_archive_to_swim_lengths(f"garmin:{activity_id}", original_path)
563
697
  if lengths:
564
698
  start_date = str(summary.get("startTimeLocal") or summary.get("startTimeGMT") or date.today().isoformat())