catence 0.2.1-beta.28 → 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 +5 -1
- package/dist/contracts/progress.d.ts +1 -1
- package/dist/contracts/progress.js +4 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/contracts/staging.d.ts +13 -1
- package/dist/contracts/staging.js +1 -1
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +4 -0
- package/dist/core/retrieval/index.js +1 -1
- package/dist/core/runtime/secrets.d.ts +3 -0
- package/dist/core/runtime/secrets.js +8 -2
- package/dist/elt/application/detached-sync.d.ts +1 -1
- package/dist/elt/application/detached-sync.js +2 -2
- package/dist/elt/application/management.d.ts +1 -1
- package/dist/elt/application/management.js +39 -12
- package/dist/elt/ingestion/providers/intervals.js +5 -0
- package/dist/elt/ingestion/providers/registry.d.ts +12 -1
- package/dist/elt/ingestion/providers/registry.js +17 -2
- package/dist/elt/normalization/normalizers.js +79 -2
- package/dist/elt/storage/migrations.d.ts +1 -1
- package/dist/elt/storage/migrations.js +61 -1
- package/dist/interfaces/cli/commands.js +9 -9
- package/dist/interfaces/http/server.js +1 -1
- package/dist/interfaces/mcp/server.js +1 -1
- package/docs/architecture.md +1 -1
- package/package.json +4 -4
- package/pyproject.toml +3 -0
- package/uv.lock +61 -0
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
|
|
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;
|
|
@@ -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.
|
|
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.
|
|
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),
|
|
@@ -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
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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('
|
|
227
|
+
log.info('Staging worker stage timings', parsed);
|
|
228
228
|
return;
|
|
229
229
|
}
|
|
230
|
-
log.error(`[
|
|
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
|
|
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
|
-
|
|
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,
|
|
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)
|
|
@@ -1016,5 +1016,65 @@ const runDynamicsFactsMigration = {
|
|
|
1016
1016
|
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
1017
1017
|
`,
|
|
1018
1018
|
};
|
|
1019
|
-
|
|
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
|
|
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
|
|
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.');
|
|
@@ -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(),
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
93
|
-
"hono": "4.13.
|
|
92
|
+
"fast-uri": "3.1.9",
|
|
93
|
+
"hono": "4.13.14",
|
|
94
94
|
"nanoid": "3.3.18",
|
|
95
|
-
"qs": "6.
|
|
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"
|