catence 0.2.1-beta.27 → 0.2.1-beta.29

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 CHANGED
@@ -10,7 +10,8 @@ As a context, I'm both an endurance athlete and a data junkie. I already got a c
10
10
 
11
11
  ## What is included
12
12
 
13
- - Garmin, Intervals.icu, and Strava ingestion with source-aware normalization.
13
+ - Garmin, Intervals.icu, Strava, and read-only TrainingPeaks ingestion (planned workouts + coach-entered zones) with source-aware normalization.
14
+ - Intervals.icu-derived fitness (daily CTL, ATL, TSB, ramp rate) is ingested and queryable through the catalog with explicit Intervals provenance; Garmin remains the source of record for activity detail and its own load numbers.
14
15
  - Read-only MCP tools, plus explicitly named, lock-guarded write tools for Strava hydration and detached syncs.
15
16
  - One Catence MCP server serves several athlete stores side by side. Every personal-data MCP call names an `athleteId`; tools act only on that store and never merge athletes implicitly. The MCP server does not authenticate callers — it trusts the `athleteId` each call names.
16
17
  - A password-protected Chainlit Console with an authenticated dashboard, a data-sync button with live progress, and in-app model management.
@@ -42,8 +43,11 @@ printf %s 'alex@example.com' | catence-data --athlete alex secret set --provider
42
43
  printf %s 'your-garmin-password' | catence-data --athlete alex secret set --provider garmin --field password --value-stdin
43
44
  printf %s 'intervals-api-key' | catence-data --athlete alex secret set --provider intervals --field apiKey --value-stdin
44
45
  printf %s '12345' | catence-data --athlete alex secret set --provider intervals --field athleteId --value-stdin
46
+ printf %s '<production-tpauth-cookie-value>' | catence-data --athlete alex secret set --provider trainingpeaks --field authCookie --value-stdin
45
47
  ```
46
48
 
49
+ TrainingPeaks is a read-only companion provider for planned workouts (next 14 days) and coach-entered zones/thresholds; the `Production_tpAuth` browser cookie expires every few weeks and needs a re-paste. See [docs/providers-trainingpeaks.md](docs/providers-trainingpeaks.md).
50
+
47
51
  Sync and create retrieval context:
48
52
 
49
53
  ```sh
@@ -1,5 +1,5 @@
1
1
  export declare const STALE_RUN_TIMEOUT_MS: number;
2
- export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "intervals", "importing", "completed", "interrupted", "failed", "timed_out"];
2
+ export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "intervals", "trainingpeaks", "planned_workouts", "zones", "athlete_settings", "importing", "completed", "interrupted", "failed", "timed_out"];
3
3
  export type SyncStage = (typeof SYNC_STAGES)[number];
4
4
  export interface SyncProgressState {
5
5
  runId: string;
@@ -12,6 +12,10 @@ export const SYNC_STAGES = [
12
12
  'activities',
13
13
  'collections',
14
14
  'intervals',
15
+ 'trainingpeaks',
16
+ 'planned_workouts',
17
+ 'zones',
18
+ 'athlete_settings',
15
19
  'importing',
16
20
  'completed',
17
21
  'interrupted',
@@ -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.1-beta.27";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.29";
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.1-beta.27';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.29';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- export declare const PROVIDERS: readonly ["intervals", "garmin", "strava"];
2
+ export declare const PROVIDERS: readonly ["intervals", "garmin", "strava", "trainingpeaks"];
3
3
  export type Provider = (typeof PROVIDERS)[number];
4
4
  export declare const STAGING_SCHEMA_VERSION = 1;
5
5
  export declare const rawObjectSchema: z.ZodObject<{
@@ -7,6 +7,7 @@ export declare const rawObjectSchema: z.ZodObject<{
7
7
  schemaVersion: z.ZodLiteral<1>;
8
8
  provider: z.ZodEnum<{
9
9
  intervals: "intervals";
10
+ trainingpeaks: "trainingpeaks";
10
11
  garmin: "garmin";
11
12
  strava: "strava";
12
13
  }>;
@@ -23,6 +24,7 @@ export declare const runManifestSchema: z.ZodObject<{
23
24
  schemaVersion: z.ZodLiteral<1>;
24
25
  provider: z.ZodEnum<{
25
26
  intervals: "intervals";
27
+ trainingpeaks: "trainingpeaks";
26
28
  garmin: "garmin";
27
29
  strava: "strava";
28
30
  }>;
@@ -35,6 +37,7 @@ export declare const sourceEntitySchema: z.ZodObject<{
35
37
  schemaVersion: z.ZodLiteral<1>;
36
38
  provider: z.ZodEnum<{
37
39
  intervals: "intervals";
40
+ trainingpeaks: "trainingpeaks";
38
41
  garmin: "garmin";
39
42
  strava: "strava";
40
43
  }>;
@@ -52,6 +55,7 @@ export declare const streamManifestSchema: z.ZodObject<{
52
55
  schemaVersion: z.ZodLiteral<1>;
53
56
  provider: z.ZodEnum<{
54
57
  intervals: "intervals";
58
+ trainingpeaks: "trainingpeaks";
55
59
  garmin: "garmin";
56
60
  strava: "strava";
57
61
  }>;
@@ -69,6 +73,7 @@ export declare const activitySyncStateSchema: z.ZodObject<{
69
73
  schemaVersion: z.ZodLiteral<1>;
70
74
  provider: z.ZodEnum<{
71
75
  intervals: "intervals";
76
+ trainingpeaks: "trainingpeaks";
72
77
  garmin: "garmin";
73
78
  strava: "strava";
74
79
  }>;
@@ -81,6 +86,7 @@ export declare const extractionErrorSchema: z.ZodObject<{
81
86
  schemaVersion: z.ZodLiteral<1>;
82
87
  provider: z.ZodEnum<{
83
88
  intervals: "intervals";
89
+ trainingpeaks: "trainingpeaks";
84
90
  garmin: "garmin";
85
91
  strava: "strava";
86
92
  }>;
@@ -94,6 +100,7 @@ export declare const stagingRecordSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
94
100
  schemaVersion: z.ZodLiteral<1>;
95
101
  provider: z.ZodEnum<{
96
102
  intervals: "intervals";
103
+ trainingpeaks: "trainingpeaks";
97
104
  garmin: "garmin";
98
105
  strava: "strava";
99
106
  }>;
@@ -105,6 +112,7 @@ export declare const stagingRecordSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
105
112
  schemaVersion: z.ZodLiteral<1>;
106
113
  provider: z.ZodEnum<{
107
114
  intervals: "intervals";
115
+ trainingpeaks: "trainingpeaks";
108
116
  garmin: "garmin";
109
117
  strava: "strava";
110
118
  }>;
@@ -120,6 +128,7 @@ export declare const stagingRecordSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
120
128
  schemaVersion: z.ZodLiteral<1>;
121
129
  provider: z.ZodEnum<{
122
130
  intervals: "intervals";
131
+ trainingpeaks: "trainingpeaks";
123
132
  garmin: "garmin";
124
133
  strava: "strava";
125
134
  }>;
@@ -136,6 +145,7 @@ export declare const stagingRecordSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
136
145
  schemaVersion: z.ZodLiteral<1>;
137
146
  provider: z.ZodEnum<{
138
147
  intervals: "intervals";
148
+ trainingpeaks: "trainingpeaks";
139
149
  garmin: "garmin";
140
150
  strava: "strava";
141
151
  }>;
@@ -152,6 +162,7 @@ export declare const stagingRecordSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
152
162
  schemaVersion: z.ZodLiteral<1>;
153
163
  provider: z.ZodEnum<{
154
164
  intervals: "intervals";
165
+ trainingpeaks: "trainingpeaks";
155
166
  garmin: "garmin";
156
167
  strava: "strava";
157
168
  }>;
@@ -163,6 +174,7 @@ export declare const stagingRecordSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
163
174
  schemaVersion: z.ZodLiteral<1>;
164
175
  provider: z.ZodEnum<{
165
176
  intervals: "intervals";
177
+ trainingpeaks: "trainingpeaks";
166
178
  garmin: "garmin";
167
179
  strava: "strava";
168
180
  }>;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- export const PROVIDERS = ['intervals', 'garmin', 'strava'];
2
+ export const PROVIDERS = ['intervals', 'garmin', 'strava', 'trainingpeaks'];
3
3
  export const STAGING_SCHEMA_VERSION = 1;
4
4
  export const rawObjectSchema = z.object({
5
5
  kind: z.literal('raw_object'), schemaVersion: z.literal(STAGING_SCHEMA_VERSION), provider: z.enum(PROVIDERS),
@@ -3,7 +3,7 @@ export type FindActivitiesRequest = {
3
3
  sports?: string[];
4
4
  /** Accepted as a convenience alias for `sports: [sport]`. */
5
5
  sport?: string;
6
- distanceKm?: [number, number];
6
+ distanceKm?: number[];
7
7
  nameContains?: string[];
8
8
  startDate?: string;
9
9
  endDate?: string;
@@ -1,4 +1,4 @@
1
- export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'run_dynamics' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear' | 'course_geometry';
1
+ export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'fitness_trend' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'run_dynamics' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear' | 'course_geometry';
2
2
  export type CatalogColumn = {
3
3
  name: string;
4
4
  type: string;
@@ -42,6 +42,10 @@ export const DATASET_CATALOG = {
42
42
  name: 'daily_metrics', relation: 'daily_metrics', description: 'Long-form provider daily facts. Metric names and units are explicit.', dateColumn: 'metric_date', provenanceColumns: ['provider', 'raw_object_hash'], permittedGroupings: ['provider', 'metric_name'],
43
43
  columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('metric_date', 'DATE', { filterable: true, groupable: true }), column('metric_name', 'VARCHAR', { filterable: true, groupable: true }), column('value_number', 'DOUBLE', { metric: true }), column('value_text', 'VARCHAR'), column('unit', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
44
44
  },
45
+ fitness_trend: {
46
+ name: 'fitness_trend', relation: 'fitness_trend', description: 'One canonical daily fitness/fatigue/form row per date (CTL, ATL, TSB) plus ramp rate and daily training load. Garmin wins whenever it has the values for that date; TrainingPeaks or Intervals.icu is a whole-day fallback, and the provider column names the winner. Source values are stored verbatim, never recomputed by Catence — query daily_metrics (metric names ctl/atl/tsb/ramp_rate/fitness_load) to compare provider observations. Missing dates mean no provider value exists, not a zero.', dateColumn: 'metric_date', provenanceColumns: ['provider'], permittedGroupings: ['provider'],
47
+ columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('metric_date', 'DATE', { filterable: true, groupable: true }), column('ctl', 'DOUBLE', { unit: 'tss/d', metric: true, description: 'Chronic training load (fitness), Intervals-derived rolling daily average.' }), column('atl', 'DOUBLE', { unit: 'tss/d', metric: true, description: 'Acute training load (fatigue), Intervals-derived rolling daily average.' }), column('tsb', 'DOUBLE', { unit: 'tss', metric: true, description: 'Training stress balance (form) = CTL - ATL as reported by Intervals.' }), column('ramp_rate', 'DOUBLE', { unit: 'tss/d/week', metric: true, description: 'CTL ramp rate as reported by Intervals.' }), column('fitness_load', 'DOUBLE', { unit: 'tss', metric: true, description: 'Daily training load (TSS/TRIMP) used for the PMC series, as reported by Intervals.' })],
48
+ },
45
49
  daily_health: {
46
50
  name: 'daily_health', relation: 'daily_health', description: 'One canonical daily-health record per date. Garmin wins whenever it has facts for that date; Intervals or another provider is a whole-day fallback. Use daily_metrics to compare source observations.', dateColumn: 'metric_date', provenanceColumns: ['provider'], permittedGroupings: ['provider'],
47
51
  columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('metric_date', 'DATE', { filterable: true, groupable: true }), column('resting_hr_bpm', 'DOUBLE', { unit: 'bpm', metric: true }), column('hrv_ms', 'DOUBLE', { unit: 'ms', metric: true }), column('sleep_seconds', 'DOUBLE', { unit: 's', metric: true }), column('sleep_score', 'DOUBLE', { metric: true }), column('stress', 'DOUBLE', { metric: true }), column('body_battery', 'DOUBLE', { metric: true }), column('readiness', 'DOUBLE', { metric: true }), column('spo2_pct', 'DOUBLE', { unit: '%', metric: true }), column('weight_kg', 'DOUBLE', { unit: 'kg', metric: true }), column('steps', 'DOUBLE', { unit: 'count', metric: true })],
@@ -88,7 +88,7 @@ export async function buildRetrievalIndex(database) {
88
88
  const domain = await database.rows(`
89
89
  SELECT provider, entity_type, remote_id, cast(occurred_on AS VARCHAR) AS occurred_on, payload_json
90
90
  FROM domain_entities
91
- WHERE entity_type IN ('event', 'workout', 'scheduled_workout', 'training_plan', 'route', 'message')
91
+ WHERE entity_type IN ('event', 'workout', 'scheduled_workout', 'planned_workout', 'training_plan', 'route', 'message')
92
92
  `);
93
93
  for (const entity of domain) {
94
94
  const fields = selectedPayloadText(payloadObject(entity.payload_json));
@@ -13,6 +13,9 @@ declare const secretSchema: z.ZodObject<{
13
13
  clientId: z.ZodOptional<z.ZodString>;
14
14
  clientSecret: z.ZodOptional<z.ZodString>;
15
15
  }, z.core.$strict>>;
16
+ trainingpeaks: z.ZodOptional<z.ZodObject<{
17
+ authCookie: z.ZodOptional<z.ZodString>;
18
+ }, z.core.$strict>>;
16
19
  }, z.core.$strict>;
17
20
  export type AthleteSecrets = z.infer<typeof secretSchema>;
18
21
  export type SecretProvider = keyof AthleteSecrets;
@@ -6,18 +6,21 @@ const secretSchema = z.object({
6
6
  garmin: z.object({ email: z.string().min(1).optional(), password: z.string().min(1).optional() }).strict().optional(),
7
7
  intervals: z.object({ apiKey: z.string().min(1).optional(), athleteId: z.string().min(1).optional() }).strict().optional(),
8
8
  strava: z.object({ clientId: z.string().min(1).optional(), clientSecret: z.string().min(1).optional() }).strict().optional(),
9
+ trainingpeaks: z.object({ authCookie: z.string().min(1).optional() }).strict().optional(),
9
10
  }).strict();
10
11
  const secretFields = {
11
12
  garmin: ['email', 'password'],
12
13
  intervals: ['apiKey', 'athleteId'],
13
14
  strava: ['clientId', 'clientSecret'],
15
+ trainingpeaks: ['authCookie'],
14
16
  };
15
- const secretProviderOrder = ['garmin', 'intervals', 'strava'];
17
+ const secretProviderOrder = ['garmin', 'intervals', 'strava', 'trainingpeaks'];
16
18
  /** Display labels for the Console credentials editor. */
17
19
  export const secretProviderLabels = {
18
20
  garmin: 'Garmin',
19
21
  intervals: 'Intervals.icu',
20
22
  strava: 'Strava',
23
+ trainingpeaks: 'TrainingPeaks',
21
24
  };
22
25
  export function providerSecretPath(paths) {
23
26
  return path.join(paths.secrets, 'providers.json');
@@ -121,7 +124,7 @@ export async function athleteProviderEnvironment(paths, base = process.env) {
121
124
  // athlete store that has not explicitly configured that provider, unless
122
125
  // the operator has opted in via CATENCE_ALLOW_ENV_SECRETS or is using a
123
126
  // per-athlete scoped variable (CATENCE_ATHLETE_<ID>_GARMIN_EMAIL etc.).
124
- for (const name of ['GARMIN_EMAIL', 'GARMIN_PASSWORD', 'INTERVALS_API_KEY', 'INTERVALS_ATHLETE_ID', 'STRAVA_CLIENT_ID', 'STRAVA_CLIENT_SECRET']) {
127
+ for (const name of ['GARMIN_EMAIL', 'GARMIN_PASSWORD', 'INTERVALS_API_KEY', 'INTERVALS_ATHLETE_ID', 'STRAVA_CLIENT_ID', 'STRAVA_CLIENT_SECRET', 'TP_AUTH_COOKIE']) {
125
128
  delete environment[name];
126
129
  }
127
130
  const garminEmail = resolveWithEnvFallback(secrets.garmin?.email, 'GARMIN_EMAIL', perAthletePrefix, base, allowGenericEnv);
@@ -130,6 +133,7 @@ export async function athleteProviderEnvironment(paths, base = process.env) {
130
133
  const intervalsAthleteId = resolveWithEnvFallback(secrets.intervals?.athleteId, 'INTERVALS_ATHLETE_ID', perAthletePrefix, base, allowGenericEnv);
131
134
  const stravaClientId = resolveWithEnvFallback(secrets.strava?.clientId, 'STRAVA_CLIENT_ID', perAthletePrefix, base, allowGenericEnv);
132
135
  const stravaClientSecret = resolveWithEnvFallback(secrets.strava?.clientSecret, 'STRAVA_CLIENT_SECRET', perAthletePrefix, base, allowGenericEnv);
136
+ const trainingpeaksAuthCookie = resolveWithEnvFallback(secrets.trainingpeaks?.authCookie, 'TP_AUTH_COOKIE', perAthletePrefix, base, allowGenericEnv);
133
137
  if (garminEmail)
134
138
  environment.GARMIN_EMAIL = garminEmail;
135
139
  if (garminPassword)
@@ -142,6 +146,8 @@ export async function athleteProviderEnvironment(paths, base = process.env) {
142
146
  environment.STRAVA_CLIENT_ID = stravaClientId;
143
147
  if (stravaClientSecret)
144
148
  environment.STRAVA_CLIENT_SECRET = stravaClientSecret;
149
+ if (trainingpeaksAuthCookie)
150
+ environment.TP_AUTH_COOKIE = trainingpeaksAuthCookie;
145
151
  return environment;
146
152
  }
147
153
  //# sourceMappingURL=secrets.js.map
@@ -1,6 +1,6 @@
1
1
  import { type SpawnOptions } from 'node:child_process';
2
2
  import type { CatencePaths } from '../../contracts/runtime.js';
3
- export declare const DETACHED_SYNC_PROVIDERS: readonly ["intervals", "garmin", "strava", "all"];
3
+ export declare const DETACHED_SYNC_PROVIDERS: readonly ["intervals", "garmin", "strava", "trainingpeaks", "all"];
4
4
  export type DetachedSyncProvider = (typeof DETACHED_SYNC_PROVIDERS)[number];
5
5
  export type DetachedSyncRequest = {
6
6
  paths: CatencePaths;
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
7
7
  import { syncProgress } from './management.js';
8
- export const DETACHED_SYNC_PROVIDERS = ['intervals', 'garmin', 'strava', 'all'];
8
+ export const DETACHED_SYNC_PROVIDERS = ['intervals', 'garmin', 'strava', 'trainingpeaks', 'all'];
9
9
  /** Raised when another sync run is already active for this store. */
10
10
  export class DetachedSyncBusyError extends Error {
11
11
  runId;
@@ -41,7 +41,7 @@ function assertValidRequest(request) {
41
41
  throw new Error('athleteId is required and must be a lowercase athlete identifier.');
42
42
  }
43
43
  if (request.provider !== undefined && !DETACHED_SYNC_PROVIDERS.includes(request.provider)) {
44
- throw new Error('provider must be intervals, garmin, strava, or all.');
44
+ throw new Error('provider must be intervals, garmin, strava, trainingpeaks, or all.');
45
45
  }
46
46
  if (request.from !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(request.from)) {
47
47
  throw new Error('from must use YYYY-MM-DD.');
@@ -1,6 +1,6 @@
1
1
  import type { CatencePaths } from '../../contracts/runtime.js';
2
2
  import { type SyncProgressSnapshot } from '../../contracts/progress.js';
3
- type SyncProviderChoice = 'intervals' | 'garmin';
3
+ type SyncProviderChoice = 'intervals' | 'garmin' | 'trainingpeaks';
4
4
  export type ProviderChoice = SyncProviderChoice | 'strava' | 'all';
5
5
  export declare function initializeDataStore(paths: CatencePaths): Promise<Record<string, unknown>>;
6
6
  export declare function syncData(paths: CatencePaths, provider: ProviderChoice, explicitFrom?: string, advanceCursor?: boolean, refreshActivities?: boolean, backfill?: boolean): Promise<Record<string, unknown>>;
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
4
4
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
5
  import { createReadStream } from 'node:fs';
6
6
  import { createInterface } from 'node:readline';
7
- import { EVENT_WINDOW_PAST_DAYS } from '../ingestion/providers/registry.js';
7
+ import { EVENT_WINDOW_PAST_DAYS, TRAININGPEAKS_PLANNED_DAYS_AHEAD } from '../ingestion/providers/registry.js';
8
8
  import { createServer } from 'node:http';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
@@ -164,7 +164,7 @@ async function backfillGarminSwimLengths(database, paths, parentRunId, log) {
164
164
  * Progress NDJSON lines (kind 'progress') are normalized and persisted as
165
165
  * throttled heartbeats; other output is forwarded to the sync logger.
166
166
  */
167
- function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptReady) {
167
+ function runStagingWorker(runId, provider, arguments_, environment, pump, log, onInterruptReady) {
168
168
  return new Promise((resolve) => {
169
169
  const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
170
170
  let killTimer;
@@ -203,7 +203,7 @@ function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptR
203
203
  parsed = JSON.parse(line);
204
204
  }
205
205
  catch {
206
- log.info(`[garmin] ${line}`);
206
+ log.info(`[${provider}] ${line}`);
207
207
  return;
208
208
  }
209
209
  if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'progress') {
@@ -212,7 +212,7 @@ function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptR
212
212
  pump.publish(state);
213
213
  return;
214
214
  }
215
- log.info(`[garmin] ${line}`);
215
+ log.info(`[${provider}] ${line}`);
216
216
  });
217
217
  buffer(child.stderr, (line) => {
218
218
  let parsed;
@@ -220,14 +220,14 @@ function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptR
220
220
  parsed = JSON.parse(line);
221
221
  }
222
222
  catch {
223
- log.error(`[garmin] ${line}`);
223
+ log.error(`[${provider}] ${line}`);
224
224
  return;
225
225
  }
226
226
  if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'stage_timings') {
227
- log.info('Garmin staging worker stage timings', parsed);
227
+ log.info('Staging worker stage timings', parsed);
228
228
  return;
229
229
  }
230
- log.error(`[garmin] ${line}`);
230
+ log.error(`[${provider}] ${line}`);
231
231
  });
232
232
  child.on('error', (error) => {
233
233
  if (killTimer)
@@ -289,7 +289,7 @@ async function syncProvider(database, paths, provider, options, log) {
289
289
  };
290
290
  const dailyWindow = explicitWindow ? await uncoveredExplicitWindow('daily') : await database.resolveIncrementalWindow(provider, 'daily', options.toDate, defaultFromDate(), 3);
291
291
  const activityWindow = explicitWindow ? await uncoveredExplicitWindow('activities') : await database.resolveIncrementalWindow(provider, 'activities', options.toDate, defaultFromDate(), 14);
292
- const hasProviderWork = provider === 'intervals' ? activityWindow !== null : dailyWindow !== null || activityWindow !== null;
292
+ const hasProviderWork = provider === 'intervals' ? activityWindow !== null : provider === 'trainingpeaks' ? true : dailyWindow !== null || activityWindow !== null;
293
293
  if (!hasProviderWork) {
294
294
  log.debug('No uncovered work; skipping provider', { provider, daily: dailyWindow ?? null, activities: activityWindow ?? null });
295
295
  return {
@@ -300,7 +300,8 @@ async function syncProvider(database, paths, provider, options, log) {
300
300
  refreshedActivities: provider === 'garmin' && options.refreshActivities, skipped: true,
301
301
  };
302
302
  }
303
- const fromDate = [dailyWindow?.fromDate, activityWindow?.fromDate].filter((date) => Boolean(date)).sort()[0];
303
+ const fromDate = [dailyWindow?.fromDate, activityWindow?.fromDate].filter((date) => Boolean(date)).sort()[0]
304
+ ?? (provider === 'trainingpeaks' ? new Date().toISOString().slice(0, 10) : '');
304
305
  const runId = await database.beginRun(provider, fromDate);
305
306
  log.info('Starting sync run', {
306
307
  runId, provider, fromDate, toDate: options.toDate,
@@ -322,6 +323,32 @@ async function syncProvider(database, paths, provider, options, log) {
322
323
  await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
323
324
  });
324
325
  }
326
+ else if (provider === 'trainingpeaks') {
327
+ const output = path.join(paths.staging, 'trainingpeaks', `${runId}.jsonl`);
328
+ await mkdir(path.dirname(output), { recursive: true });
329
+ const fromDate = new Date().toISOString().slice(0, 10);
330
+ const toDate = new Date(Date.now() + TRAININGPEAKS_PLANNED_DAYS_AHEAD * 86_400_000).toISOString().slice(0, 10);
331
+ const arguments_ = ['run', 'python', '-m', 'python.catence.providers.trainingpeaks.cli', '--from', fromDate, '--to', toDate, '--data-dir', paths.root, '--output', output, '--run-id', runId];
332
+ let interruptChild = null;
333
+ await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
334
+ const environment = await athleteProviderEnvironment(paths);
335
+ if (!environment.TP_AUTH_COOKIE) {
336
+ throw new Error('TrainingPeaks auth cookie is not configured. Set it with: catence-data --athlete <id> secret set --provider trainingpeaks --field authCookie --value-stdin');
337
+ }
338
+ extractionStarted = true;
339
+ pump.setStage('trainingpeaks');
340
+ const outcome = await runStagingWorker(runId, 'trainingpeaks', arguments_, {
341
+ cwd: packageRoot(),
342
+ env: { ...environment, UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
343
+ }, pump, log, (kill) => { interruptChild = kill; });
344
+ if (!guard.interrupted && outcome.code !== 0) {
345
+ throw new Error(`TrainingPeaks staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
346
+ }
347
+ });
348
+ if (!guard.interrupted && existsSync(output)) {
349
+ await importJsonl(database, runId, output, log);
350
+ }
351
+ }
325
352
  else {
326
353
  const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
327
354
  const knownActivities = path.join(paths.staging, 'garmin', `${runId}.known-activities.json`);
@@ -366,7 +393,7 @@ async function syncProvider(database, paths, provider, options, log) {
366
393
  const workerStartedAt = Date.now();
367
394
  let interruptChild = null;
368
395
  await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
369
- const outcome = await runGarminWorker(runId, arguments_, {
396
+ const outcome = await runStagingWorker(runId, 'garmin', arguments_, {
370
397
  cwd: packageRoot(),
371
398
  env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
372
399
  }, pump, log, (kill) => { interruptChild = kill; });
@@ -422,7 +449,7 @@ async function syncProvider(database, paths, provider, options, log) {
422
449
  const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
423
450
  log.info('Sync run finished', { runId, provider, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
424
451
  }
425
- if (!guard.interrupted && options.advanceCursor && !options.explicitFrom && extractionStarted) {
452
+ if (!guard.interrupted && options.advanceCursor && !options.explicitFrom && extractionStarted && provider !== 'trainingpeaks') {
426
453
  await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
427
454
  await database.advanceIncrementalCursor(provider, 'activities', runId, options.toDate, 14);
428
455
  }
@@ -454,7 +481,7 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
454
481
  for (const runId of timedOutRuns.runIds) {
455
482
  await removeProgressSidecar(paths, runId).catch(() => undefined);
456
483
  }
457
- const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
484
+ const providers = provider === 'all' ? ['intervals', 'garmin', 'trainingpeaks'] : [provider];
458
485
  const toDate = new Date().toISOString().slice(0, 10);
459
486
  const runs = [];
460
487
  for (const source of providers)
@@ -98,6 +98,11 @@ export class IntervalsExtractor {
98
98
  // detail, files, streams, and structured activity data.
99
99
  await this.database.recordActivitySyncState('intervals', activityId, summaryHash, runId, false);
100
100
  }
101
+ // The same payload also carries the rolling daily PMC numbers
102
+ // (icu_ctl/icu_atl/...) embedded per activity; there is no separate
103
+ // fitness endpoint. Fan them out as fitness_summary entities so the
104
+ // normalizer can store the day's provider-reported CTL/ATL/load.
105
+ await this.importEntities('fitness_summary', 'activities', payload, null, runId);
101
106
  }
102
107
  }
103
108
  }
@@ -8,7 +8,10 @@ export declare const intervalsReadRegistry: readonly EndpointSpec[];
8
8
  /**
9
9
  * Intervals is an analysis companion to Garmin: its activity-list payload
10
10
  * supplies the separately named training calculations without duplicating
11
- * Garmin's activity detail, files, streams, or account data.
11
+ * Garmin's activity detail, files, streams, or account data. There is no
12
+ * /fitness endpoint — the daily PMC numbers (icu_ctl/icu_atl/...) ride
13
+ * embedded on each activity, and the extractor fans them out into
14
+ * fitness_summary entities so they reach the fitness_trend dataset.
12
15
  */
13
16
  export declare const intervalsSecondaryReadRegistry: EndpointSpec[];
14
17
  /** How far back and ahead of today the planned-events fetch reaches. */
@@ -28,5 +31,13 @@ export declare const intervalsActivityReadEndpoints: readonly ["activity", "stre
28
31
  /** Read-only methods exposed by python-garminconnect, grouped for manifest tests and worker dispatch. */
29
32
  export declare const garminReadRegistry: readonly EndpointSpec[];
30
33
  export declare const stravaReadRegistry: readonly EndpointSpec[];
34
+ /**
35
+ * TrainingPeaks is a companion provider in the Intervals role: planned-workout
36
+ * and coach-entered zone/threshold provenance. Completed activities stay with
37
+ * Garmin; TP zone rows are never merged with Garmin's device-detected values.
38
+ */
39
+ export declare const trainingpeaksReadRegistry: readonly EndpointSpec[];
40
+ /** How far ahead of today the planned-workout window reaches. */
41
+ export declare const TRAININGPEAKS_PLANNED_DAYS_AHEAD = 14;
31
42
  export declare const allReadOnlyEndpoints: readonly EndpointSpec[];
32
43
  export declare function assertReadOnlyRegistry(): void;
@@ -19,7 +19,10 @@ export const intervalsReadRegistry = registry('intervals', [
19
19
  /**
20
20
  * Intervals is an analysis companion to Garmin: its activity-list payload
21
21
  * supplies the separately named training calculations without duplicating
22
- * Garmin's activity detail, files, streams, or account data.
22
+ * Garmin's activity detail, files, streams, or account data. There is no
23
+ * /fitness endpoint — the daily PMC numbers (icu_ctl/icu_atl/...) ride
24
+ * embedded on each activity, and the extractor fans them out into
25
+ * fitness_summary entities so they reach the fitness_trend dataset.
23
26
  */
24
27
  export const intervalsSecondaryReadRegistry = intervalsReadRegistry.filter((endpoint) => endpoint.name === 'athlete' || endpoint.name === 'activities' || endpoint.name === 'events');
25
28
  /** How far back and ahead of today the planned-events fetch reaches. */
@@ -70,7 +73,19 @@ export const stravaReadRegistry = registry('strava', [
70
73
  ['activity_candidates', 'date_range', 'activity'], ['activity_detail', 'activity', 'activity'],
71
74
  ['segment', 'child', 'segment'], ['segment_efforts', 'child', 'segment_effort'],
72
75
  ]);
73
- export const allReadOnlyEndpoints = [...intervalsReadRegistry, ...garminReadRegistry, ...stravaReadRegistry];
76
+ /**
77
+ * TrainingPeaks is a companion provider in the Intervals role: planned-workout
78
+ * and coach-entered zone/threshold provenance. Completed activities stay with
79
+ * Garmin; TP zone rows are never merged with Garmin's device-detected values.
80
+ */
81
+ export const trainingpeaksReadRegistry = registry('trainingpeaks', [
82
+ ['planned_workouts', 'date_range', 'planned_workout'],
83
+ ['zones', 'collection', 'zone_setting'],
84
+ ['athlete_settings', 'singleton', 'profile'],
85
+ ]);
86
+ /** How far ahead of today the planned-workout window reaches. */
87
+ export const TRAININGPEAKS_PLANNED_DAYS_AHEAD = 14;
88
+ export const allReadOnlyEndpoints = [...intervalsReadRegistry, ...garminReadRegistry, ...stravaReadRegistry, ...trainingpeaksReadRegistry];
74
89
  export function assertReadOnlyRegistry() {
75
90
  for (const endpoint of allReadOnlyEndpoints) {
76
91
  if (endpoint.readOnly !== true)
@@ -274,6 +274,8 @@ export async function importSourceEntity(database, entity) {
274
274
  await importDailyMetrics(database, entity.provider, entity.remoteId, entity.occurredOn, payload, rawHash);
275
275
  if (entity.provider === 'garmin' && entity.entityType === 'body_composition')
276
276
  await importGarminWeight(database, entity.occurredOn, payload, rawHash);
277
+ if (entity.provider === 'intervals' && entity.entityType === 'fitness_summary')
278
+ await importIntervalsFitnessSummary(database, entity.remoteId, entity.occurredOn, payload, rawHash);
277
279
  if (entity.entityType === 'nutrition_day' || entity.entityType === 'nutrition_log')
278
280
  await importNutrition(database, entity.provider, entity.occurredOn, entity.remoteId, payload, rawHash);
279
281
  if (entity.provider === 'garmin' && entity.entityType === 'training_metric')
@@ -296,6 +298,58 @@ export async function importSourceEntity(database, entity) {
296
298
  await importActivityPowerBest(database, entity.parentRemoteId, payload, rawHash);
297
299
  if (entity.provider === 'garmin' && entity.entityType === 'course')
298
300
  await importGarminCourse(database, entity.remoteId, payload, rawHash);
301
+ if (entity.provider === 'trainingpeaks' && entity.entityType === 'zone_setting')
302
+ await importTrainingPeaksZoneSetting(database, entity.remoteId, entity.occurredOn, payload, rawHash);
303
+ if (entity.provider === 'trainingpeaks' && entity.entityType === 'planned_workout')
304
+ await importTrainingPeaksPlannedWorkout(database, entity.remoteId, entity.occurredOn, payload, rawHash);
305
+ }
306
+ // TrainingPeaks zone settings are coach-manual-data provenance. Each metric
307
+ // group (power/HR/pace per workout type) becomes training_metric_observations
308
+ // rows with an explicit trainingpeaks source_type. They are NEVER merged with
309
+ // or preferred over Garmin's device-detected values: the query layer presents
310
+ // both with provenance (neither is preferred), exactly like the daily_health
311
+ // fallback rule except that here both sources stay visible.
312
+ async function importTrainingPeaksZoneSetting(database, remoteId, occurredOn, payload, rawHash) {
313
+ const [workoutTypeId, metricKey] = remoteId.split(':');
314
+ const threshold = firstNumber(payload, ['threshold']);
315
+ const observedAt = `${datePart(occurredOn) ?? new Date().toISOString().slice(0, 10)}T00:00:00Z`;
316
+ if (threshold === null)
317
+ return;
318
+ const metric = metricForZoneMetric(metricKey, Number(workoutTypeId));
319
+ if (!metric)
320
+ return;
321
+ await upsertTrainingMetricObservation(database, {
322
+ provider: 'trainingpeaks',
323
+ observationId: `trainingpeaks:zone:${remoteId}`,
324
+ metricName: metric.metric,
325
+ sport: metric.sport,
326
+ observedAt,
327
+ value: threshold,
328
+ unit: metric.unit,
329
+ dimensions: { workoutTypeId: Number(workoutTypeId), calculationMethod: firstString(payload, ['calculationMethod']), zoneCalculatorId: firstNumber(payload, ['zoneCalculatorId']), zones: payload.zones ?? null },
330
+ sourceType: 'trainingpeaks_zone_setting',
331
+ sourceRemoteId: remoteId,
332
+ activitySourceId: null,
333
+ rawHash,
334
+ });
335
+ }
336
+ function metricForZoneMetric(metricKey, workoutTypeId) {
337
+ if (metricKey === 'power' && workoutTypeId === 2)
338
+ return { metric: 'cycling_ftp_w', unit: 'W', sport: 'cycling' };
339
+ if (metricKey === 'heartrate' && workoutTypeId === 2)
340
+ return { metric: 'cycling_lthr_bpm', unit: 'bpm', sport: 'cycling' };
341
+ if (metricKey === 'heartrate' && workoutTypeId === 3)
342
+ return { metric: 'running_lthr_bpm', unit: 'bpm', sport: 'running' };
343
+ if (metricKey === 'speed' && workoutTypeId === 3)
344
+ return { metric: 'running_threshold_pace_s_per_km', unit: 's_per_km', sport: 'running' };
345
+ return null;
346
+ }
347
+ // Planned workouts join the same domain family Garmin scheduled_workouts
348
+ // populate, tagged by provider. The domain upsert above already stored the
349
+ // raw entity; this hook only needs to exist for future field-level mapping,
350
+ // so it intentionally records nothing extra today.
351
+ async function importTrainingPeaksPlannedWorkout(_database, _remoteId, _occurredOn, _payload, _rawHash) {
352
+ return;
299
353
  }
300
354
  async function importActivity(database, provider, remoteId, payload, rawHash) {
301
355
  const fields = activityFields(provider, payload);
@@ -323,12 +377,12 @@ function isCyclingSport(sport) {
323
377
  async function upsertTrainingMetricObservation(database, values) {
324
378
  await database.run(`INSERT INTO training_metric_observations
325
379
  (observation_id, provider, metric_name, sport, observed_at, value_number, value_text, unit, device_id, dimensions_json, source_type, source_remote_id, activity_source_id, raw_object_hash)
326
- VALUES ($observationId, 'garmin', $metricName, $sport, $observedAt, $value, $valueText, $unit, $deviceId, $dimensions, $sourceType, $sourceRemoteId, $activitySourceId, $rawHash)
380
+ VALUES ($observationId, $provider, $metricName, $sport, $observedAt, $value, $valueText, $unit, $deviceId, $dimensions, $sourceType, $sourceRemoteId, $activitySourceId, $rawHash)
327
381
  ON CONFLICT (observation_id) DO UPDATE SET
328
382
  sport = excluded.sport, observed_at = excluded.observed_at, value_number = excluded.value_number, value_text = excluded.value_text,
329
383
  unit = excluded.unit, device_id = excluded.device_id, dimensions_json = excluded.dimensions_json,
330
384
  source_type = excluded.source_type, source_remote_id = excluded.source_remote_id,
331
- activity_source_id = excluded.activity_source_id, raw_object_hash = excluded.raw_object_hash`, { ...values, value: values.value ?? null, valueText: values.valueText ?? null, unit: values.unit ?? null, deviceId: values.deviceId ?? null, dimensions: json(values.dimensions ?? {}) });
385
+ activity_source_id = excluded.activity_source_id, raw_object_hash = excluded.raw_object_hash`, { ...values, provider: values.provider ?? 'garmin', value: values.value ?? null, valueText: values.valueText ?? null, unit: values.unit ?? null, deviceId: values.deviceId ?? null, dimensions: json(values.dimensions ?? {}) });
332
386
  }
333
387
  async function importGarminCyclingFtpFromActivity(database, remoteId, payload, rawHash) {
334
388
  const fields = activityFields('garmin', payload);
@@ -1090,6 +1144,29 @@ async function importGarminWeight(database, date, payload, rawHash) {
1090
1144
  await upsertDailyMetric(database, 'garmin', metricDate, 'weight_kg', kg, null, 'kg', rawHash);
1091
1145
  }
1092
1146
  }
1147
+ // Intervals-derived PMC values. The provider computes these (post-activity
1148
+ // snapshots in activity_summary and rolling daily values in fitness); Catence
1149
+ // stores them verbatim as provider observations, never recomputed, and keeps
1150
+ // the Intervals provenance explicit so a coach-entered or Garmin value is
1151
+ // never conflated with them.
1152
+ const intervalsFitnessMetricMap = [
1153
+ { name: 'ctl', unit: 'tss/d', keys: ['icu_ctl', 'ctl'] },
1154
+ { name: 'atl', unit: 'tss/d', keys: ['icu_atl', 'atl'] },
1155
+ { name: 'tsb', unit: 'tss', keys: ['icu_tsb', 'tsb'] },
1156
+ { name: 'ramp_rate', unit: 'tss/d/week', keys: ['icu_ramp_rate', 'ramp_rate', 'rampRate'] },
1157
+ { name: 'fitness_load', unit: 'tss', keys: ['icu_training_load', 'training_load'] },
1158
+ ];
1159
+ async function importIntervalsFitnessSummary(database, remoteId, date, payload, rawHash) {
1160
+ const metricDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'start_date_local', 'start_date'])) ?? null;
1161
+ if (!metricDate)
1162
+ return;
1163
+ for (const metric of intervalsFitnessMetricMap) {
1164
+ const value = firstNumber(payload, metric.keys);
1165
+ if (value === null)
1166
+ continue;
1167
+ await upsertDailyMetric(database, 'intervals', metricDate, metric.name, value, null, metric.unit, rawHash);
1168
+ }
1169
+ }
1093
1170
  async function importNutrition(database, provider, date, remoteId, payload, rawHash) {
1094
1171
  const nutritionDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
1095
1172
  if (!nutritionDate)
@@ -1,3 +1,3 @@
1
1
  import { type Migration } from './migrations.legacy.js';
2
- export declare const migrations: Migration[];
3
2
  export type { Migration };
3
+ export declare const migrations: Migration[];
@@ -1016,5 +1016,65 @@ const runDynamicsFactsMigration = {
1016
1016
  UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
1017
1017
  `,
1018
1018
  };
1019
- export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration, canonicalTrainingDerivedAvgPowerMigration, courseGeometryMigration, activityDecouplingFactsMigration, runDynamicsFactsMigration];
1019
+ const intervalsFitnessTrendMigration = {
1020
+ version: 19,
1021
+ name: 'intervals_fitness_trend',
1022
+ sql: `
1023
+ -- Catalog exposure for Intervals-derived daily CTL/ATL/TSB (plus ramp
1024
+ -- rate and daily load). The values are stored verbatim in daily_metrics
1025
+ -- as provider observations; this view pivots them into one row per
1026
+ -- provider/date so the catalog can serve them with explicit Intervals
1027
+ -- provenance. Catence never recomputes these numbers.
1028
+ CREATE TABLE IF NOT EXISTS daily_metrics (
1029
+ provider VARCHAR NOT NULL,
1030
+ metric_date DATE NOT NULL,
1031
+ metric_name VARCHAR NOT NULL,
1032
+ value_number DOUBLE,
1033
+ value_text VARCHAR,
1034
+ unit VARCHAR,
1035
+ raw_object_hash VARCHAR,
1036
+ PRIMARY KEY (provider, metric_date, metric_name)
1037
+ );
1038
+ CREATE OR REPLACE VIEW fitness_trend AS
1039
+ SELECT provider, metric_date,
1040
+ max(value_number) FILTER (WHERE metric_name = 'ctl') AS ctl,
1041
+ max(value_number) FILTER (WHERE metric_name = 'atl') AS atl,
1042
+ max(value_number) FILTER (WHERE metric_name = 'tsb') AS tsb,
1043
+ max(value_number) FILTER (WHERE metric_name = 'ramp_rate') AS ramp_rate,
1044
+ max(value_number) FILTER (WHERE metric_name = 'fitness_load') AS fitness_load
1045
+ FROM daily_metrics
1046
+ WHERE metric_name IN ('ctl', 'atl', 'tsb', 'ramp_rate', 'fitness_load')
1047
+ GROUP BY provider, metric_date;
1048
+ `,
1049
+ };
1050
+ const unifiedFitnessTrendMigration = {
1051
+ version: 20,
1052
+ name: 'unified_fitness_trend',
1053
+ sql: `
1054
+ -- One canonical fitness/fatigue/form row per date: Garmin wins whenever
1055
+ -- it has the values for that date, TrainingPeaks or Intervals.icu is a
1056
+ -- whole-day fallback (the daily_health rule), and the provider column
1057
+ -- names the winner. Source values stay verbatim in daily_metrics as
1058
+ -- per-provider observations — this view only picks, it never recomputes.
1059
+ CREATE OR REPLACE VIEW fitness_trend AS
1060
+ WITH provider_day AS (
1061
+ SELECT provider, metric_date,
1062
+ max(value_number) FILTER (WHERE metric_name = 'ctl') AS ctl,
1063
+ max(value_number) FILTER (WHERE metric_name = 'atl') AS atl,
1064
+ max(value_number) FILTER (WHERE metric_name = 'tsb') AS tsb,
1065
+ max(value_number) FILTER (WHERE metric_name = 'ramp_rate') AS ramp_rate,
1066
+ max(value_number) FILTER (WHERE metric_name = 'fitness_load') AS fitness_load
1067
+ FROM daily_metrics
1068
+ WHERE metric_name IN ('ctl', 'atl', 'tsb', 'ramp_rate', 'fitness_load')
1069
+ GROUP BY provider, metric_date
1070
+ )
1071
+ SELECT provider, metric_date, ctl, atl, tsb, ramp_rate, fitness_load
1072
+ FROM provider_day
1073
+ QUALIFY row_number() OVER (
1074
+ PARTITION BY metric_date
1075
+ ORDER BY CASE provider WHEN 'garmin' THEN 1 WHEN 'trainingpeaks' THEN 2 ELSE 3 END
1076
+ ) = 1;
1077
+ `,
1078
+ };
1079
+ export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration, canonicalTrainingDerivedAvgPowerMigration, courseGeometryMigration, activityDecouplingFactsMigration, runDynamicsFactsMigration, intervalsFitnessTrendMigration, unifiedFitnessTrendMigration];
1020
1080
  //# sourceMappingURL=migrations.js.map
@@ -143,12 +143,12 @@ athleteCommand.command('add')
143
143
  });
144
144
  const secretCommand = program.command('secret').description('Write or remove a provider value in one athlete’s owner-only secret store.');
145
145
  secretCommand.command('set')
146
- .requiredOption('--provider <provider>', 'garmin, intervals, or strava')
146
+ .requiredOption('--provider <provider>', 'garmin, intervals, strava, or trainingpeaks')
147
147
  .requiredOption('--field <field>', 'provider secret field')
148
148
  .requiredOption('--value-stdin', 'read the secret from stdin without putting it in shell history')
149
149
  .action(async (options) => {
150
- if (!['garmin', 'intervals', 'strava'].includes(options.provider))
151
- throw new Error('--provider must be garmin, intervals, or strava.');
150
+ if (!['garmin', 'intervals', 'strava', 'trainingpeaks'].includes(options.provider))
151
+ throw new Error('--provider must be garmin, intervals, strava, or trainingpeaks.');
152
152
  const { paths } = await currentAthlete();
153
153
  await setAthleteSecret(paths, options.provider, options.field, await stdinSecret());
154
154
  process.stdout.write(`${JSON.stringify({ athleteId: program.opts().athlete, provider: options.provider, field: options.field, stored: true }, null, 2)}\n`);
@@ -185,22 +185,22 @@ program.command('demo')
185
185
  process.stdout.write(`${JSON.stringify({ ...result, home: demoCatalog.root, athleteId: 'demo' }, null, 2)}\n`);
186
186
  });
187
187
  program.command('sync')
188
- .requiredOption('--provider <provider>', 'intervals, garmin, strava, or all')
188
+ .requiredOption('--provider <provider>', 'intervals, garmin, strava, trainingpeaks, or all')
189
189
  .option('--from <date>', 'explicit ISO date range; otherwise use incremental cursors')
190
190
  .option('--refresh', 're-fetch Garmin activity details, files, and streams even when summaries are unchanged')
191
191
  .action(async (options) => {
192
- if (!['intervals', 'garmin', 'strava', 'all'].includes(options.provider))
193
- throw new Error('--provider must be intervals, garmin, strava, or all.');
192
+ if (!['intervals', 'garmin', 'strava', 'trainingpeaks', 'all'].includes(options.provider))
193
+ throw new Error('--provider must be intervals, garmin, strava, trainingpeaks, or all.');
194
194
  const { paths } = await currentAthlete();
195
195
  process.stdout.write(`${JSON.stringify(await syncData(paths, options.provider, options.from, true, options.refresh ?? false), null, 2)}\n`);
196
196
  });
197
197
  program.command('backfill')
198
198
  .requiredOption('--from <date>', 'ISO date at which to begin backfill')
199
- .option('--provider <provider>', 'intervals, garmin, strava, or all', 'all')
199
+ .option('--provider <provider>', 'intervals, garmin, strava, trainingpeaks, or all', 'all')
200
200
  .option('--refresh', 're-fetch and upsert data already covered by the requested range')
201
201
  .action(async (options) => {
202
- if (!['intervals', 'garmin', 'strava', 'all'].includes(options.provider))
203
- throw new Error('--provider must be intervals, garmin, strava, or all.');
202
+ if (!['intervals', 'garmin', 'strava', 'trainingpeaks', 'all'].includes(options.provider))
203
+ throw new Error('--provider must be intervals, garmin, strava, trainingpeaks, or all.');
204
204
  const { paths } = await currentAthlete();
205
205
  process.stdout.write(`${JSON.stringify(await syncData(paths, options.provider, options.from, false, options.refresh ?? false, true), null, 2)}\n`);
206
206
  });
@@ -388,7 +388,7 @@ function parseSyncBody(body) {
388
388
  if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
389
389
  throw new Error('athleteId must be a string.');
390
390
  if (raw.provider !== undefined && (typeof raw.provider !== 'string' || !DETACHED_SYNC_PROVIDERS.includes(raw.provider))) {
391
- throw new Error('provider must be intervals, garmin, strava, or all.');
391
+ throw new Error('provider must be intervals, garmin, strava, trainingpeaks, or all.');
392
392
  }
393
393
  if (raw.from !== undefined && (typeof raw.from !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.from))) {
394
394
  throw new Error('from must use YYYY-MM-DD.');
@@ -619,10 +619,40 @@ async function handleUnauthenticatedDiscovery(request, response, paths) {
619
619
  * tool definitions drop the SDK's `execution` field — neither the per-result
620
620
  * `_meta.serverInfo` nor `execution` appeared in the revision's schemas for
621
621
  * earlier SDKs, but the revision asks for the former and omits the latter.
622
+ * Tool schemas also drop `propertyNames`, which strict validators reject and
623
+ * whose only instance merely restates JSON's string-key rule.
622
624
  * Errors and notifications carry no result and pass through.
623
625
  */
624
626
  const MODERN_CACHEABLE_METHODS = new Set(['server/discover', 'tools/list', 'prompts/list', 'resources/list', 'resources/templates/list', 'resources/read']);
625
627
  const MODERN_RESULT_TTL_MS = 300_000;
628
+ /** Copy a JSON schema without `propertyNames` keywords; returns the original when nothing was removed. */
629
+ function stripPropertyNames(value) {
630
+ if (Array.isArray(value)) {
631
+ let changed = false;
632
+ const copy = value.map((item) => {
633
+ const next = stripPropertyNames(item);
634
+ if (next !== item)
635
+ changed = true;
636
+ return next;
637
+ });
638
+ return changed ? copy : value;
639
+ }
640
+ if (typeof value !== 'object' || value === null)
641
+ return value;
642
+ let changed = false;
643
+ const copy = {};
644
+ for (const [key, item] of Object.entries(value)) {
645
+ if (key === 'propertyNames') {
646
+ changed = true;
647
+ continue;
648
+ }
649
+ const next = stripPropertyNames(item);
650
+ if (next !== item)
651
+ changed = true;
652
+ copy[key] = next;
653
+ }
654
+ return changed ? copy : value;
655
+ }
626
656
  /** Rewrite one JSON-RPC payload; null when it carries nothing to change. */
627
657
  function frameModernRpcPayload(payload, method) {
628
658
  let envelope;
@@ -651,10 +681,23 @@ function frameModernRpcPayload(payload, method) {
651
681
  if (method === 'tools/list' && Array.isArray(framed.tools)) {
652
682
  let stripped = false;
653
683
  const tools = framed.tools.map((tool) => {
654
- if (typeof tool !== 'object' || tool === null || Array.isArray(tool) || !('execution' in tool))
684
+ if (typeof tool !== 'object' || tool === null || Array.isArray(tool))
655
685
  return tool;
656
686
  const copy = { ...tool };
657
- delete copy.execution;
687
+ let edited = false;
688
+ if ('execution' in copy) {
689
+ delete copy.execution;
690
+ edited = true;
691
+ }
692
+ if ('inputSchema' in copy) {
693
+ const schema = stripPropertyNames(copy.inputSchema);
694
+ if (schema !== copy.inputSchema) {
695
+ copy.inputSchema = schema;
696
+ edited = true;
697
+ }
698
+ }
699
+ if (!edited)
700
+ return tool;
658
701
  stripped = true;
659
702
  return copy;
660
703
  });
@@ -322,7 +322,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
322
322
  title: 'Start a detached data sync',
323
323
  description: 'Write-only sync trigger, named like the other explicit lock-guarded write tools. Spawns one detached catence-data sync process for this athlete and returns immediately with the run handle and log file; it refuses while another sync run is active. Track the run with catence_sync_progress; this tool never waits for completion. Pass backfill with a from date to extend history backwards without advancing the incremental cursors (from is required when backfill is set).',
324
324
  inputSchema: {
325
- provider: z.enum(['intervals', 'garmin', 'strava', 'all']).optional(),
325
+ provider: z.enum(['intervals', 'garmin', 'strava', 'trainingpeaks', 'all']).optional(),
326
326
  from: z.string().date().optional(),
327
327
  refresh: z.boolean().optional(),
328
328
  backfill: z.boolean().optional(),
@@ -583,7 +583,11 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
583
583
  inputSchema: {
584
584
  sport: z.string().min(1).optional(),
585
585
  sports: z.array(z.string().min(1)).min(1).max(12).optional(),
586
- distanceKm: z.tuple([z.number().min(0), z.number().min(0)]).optional(),
586
+ // Tuple-form `items` is valid draft-07 but strict clients reject it
587
+ // (ChatGPT's connector validator flagged exactly this as "Invalid MCP
588
+ // tool schema"). A bounded two-number array keeps the same contract; the
589
+ // service still enforces the ascending [minimum, maximum] order.
590
+ distanceKm: z.array(z.number().min(0)).min(2).max(2).optional(),
587
591
  nameContains: z.array(z.string().min(1)).min(1).max(12).optional(),
588
592
  startDate: z.string().date().optional(), endDate: z.string().date().optional(),
589
593
  sort: z.enum(['date_desc', 'date_asc']).optional(), limit: z.number().int().min(1).max(100).optional(), cursor: z.string().min(1).optional(),
@@ -34,7 +34,7 @@ src/interfaces/ CLI and MCP transport adapters and executable entry points
34
34
 
35
35
  ### 1. Data fetching
36
36
 
37
- Garmin Connect provides the complete local record. Intervals.icu contributes only its separately named activity-analysis values (for example training load, RPE, feel, and weighted power). Strava is a deliberately sparse, targeted source for current gear and selected activity segment enrichment.
37
+ Garmin Connect provides the complete local record. Intervals.icu contributes only its separately named activity-analysis values (for example training load, RPE, feel, weighted power, and its derived daily CTL/ATL/TSB fitness trend). TrainingPeaks is a read-only companion in the same role: planned workouts for the next 14 days and coach-entered zone/threshold settings, consumed from the trainingpeaks-mcp-core fork via cookie auth; its zone values carry explicit coach-manual provenance and are never merged with Garmin's device-detected ones. Strava is a deliberately sparse, targeted source for current gear and selected activity segment enrichment.
38
38
 
39
39
  For Garmin, we will use [python-garminconnect](https://github.com/cyberjunky/python-garminconnect) as it is the most mature and widely used library for this purpose. On the Intervals.icu side, the best option available right now is [node-intervals-icue](https://github.com/paladini/node-intervals-icu).
40
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.27",
3
+ "version": "0.2.1-beta.29",
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": {
@@ -89,9 +89,9 @@
89
89
  "vitest": "^5.0.0"
90
90
  },
91
91
  "overrides": {
92
- "fast-uri": "3.1.8",
93
- "hono": "4.13.7",
92
+ "fast-uri": "3.1.9",
93
+ "hono": "4.13.14",
94
94
  "nanoid": "3.3.18",
95
- "qs": "6.16.0"
95
+ "qs": "6.17.1"
96
96
  }
97
97
  }
package/pyproject.toml CHANGED
@@ -11,11 +11,14 @@ dependencies = [
11
11
  "pyarrow>=18.0.0",
12
12
  "pydantic>=2.0.0",
13
13
  "stravalib>=2.5.0",
14
+ "tp-mcp>=3.2.0",
14
15
  ]
15
16
 
16
17
  [tool.uv.sources]
17
18
  # fork commit
18
19
  garminconnect = { git = "https://github.com/rifusaki/python-garminconnect.git", rev = "38b3d5003ae348de384f679976d0527316775f94" }
20
+ # fork commit (trainingpeaks-mcp-core): client/auth core only, consumed like python-garminconnect
21
+ tp-mcp = { git = "https://github.com/rifusaki/trainingpeaks-mcp-core.git", rev = "ec883a697c77f6864a4849539729eff2daac646e" }
19
22
 
20
23
  [tool.uv]
21
24
  package = false
package/uv.lock CHANGED
@@ -11,6 +11,19 @@ wheels = [
11
11
  { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
12
12
  ]
13
13
 
14
+ [[package]]
15
+ name = "anyio"
16
+ version = "4.15.1"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ dependencies = [
19
+ { name = "idna" },
20
+ { name = "typing-extensions", marker = "python_full_version < '3.15'" },
21
+ ]
22
+ sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
23
+ wheels = [
24
+ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
25
+ ]
26
+
14
27
  [[package]]
15
28
  name = "arrow"
16
29
  version = "1.4.0"
@@ -36,6 +49,7 @@ dependencies = [
36
49
  { name = "pyarrow" },
37
50
  { name = "pydantic" },
38
51
  { name = "stravalib" },
52
+ { name = "tp-mcp" },
39
53
  ]
40
54
 
41
55
  [package.dev-dependencies]
@@ -52,6 +66,7 @@ requires-dist = [
52
66
  { name = "pyarrow", specifier = ">=18.0.0" },
53
67
  { name = "pydantic", specifier = ">=2.0.0" },
54
68
  { name = "stravalib", specifier = ">=2.5.0" },
69
+ { name = "tp-mcp", git = "https://github.com/rifusaki/trainingpeaks-mcp-core.git?rev=ec883a697c77f6864a4849539729eff2daac646e" },
55
70
  ]
56
71
 
57
72
  [package.metadata.requires-dev]
@@ -326,6 +341,43 @@ dependencies = [
326
341
  { name = "ua-generator" },
327
342
  ]
328
343
 
344
+ [[package]]
345
+ name = "h11"
346
+ version = "0.16.0"
347
+ source = { registry = "https://pypi.org/simple" }
348
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
349
+ wheels = [
350
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
351
+ ]
352
+
353
+ [[package]]
354
+ name = "httpcore"
355
+ version = "1.0.9"
356
+ source = { registry = "https://pypi.org/simple" }
357
+ dependencies = [
358
+ { name = "certifi" },
359
+ { name = "h11" },
360
+ ]
361
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
362
+ wheels = [
363
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
364
+ ]
365
+
366
+ [[package]]
367
+ name = "httpx"
368
+ version = "0.28.1"
369
+ source = { registry = "https://pypi.org/simple" }
370
+ dependencies = [
371
+ { name = "anyio" },
372
+ { name = "certifi" },
373
+ { name = "httpcore" },
374
+ { name = "idna" },
375
+ ]
376
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
377
+ wheels = [
378
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
379
+ ]
380
+
329
381
  [[package]]
330
382
  name = "idna"
331
383
  version = "3.18"
@@ -659,6 +711,15 @@ wheels = [
659
711
  { url = "https://files.pythonhosted.org/packages/f4/0a/9c510377cca40b6bebe915d451126db413391664db88ff50fe02a7be7709/stravalib-2.5.0-py3-none-any.whl", hash = "sha256:723422a3f06fd7f213b9b4b813f730a481fd0869b5b5e6a350f4823a1063d092", size = 128193, upload-time = "2026-06-01T13:24:24.697Z" },
660
712
  ]
661
713
 
714
+ [[package]]
715
+ name = "tp-mcp"
716
+ version = "3.2.0"
717
+ source = { git = "https://github.com/rifusaki/trainingpeaks-mcp-core.git?rev=ec883a697c77f6864a4849539729eff2daac646e#ec883a697c77f6864a4849539729eff2daac646e" }
718
+ dependencies = [
719
+ { name = "httpx" },
720
+ { name = "pydantic" },
721
+ ]
722
+
662
723
  [[package]]
663
724
  name = "typing-extensions"
664
725
  version = "4.16.0"