endurance-coach 0.1.0
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/LICENSE.md +21 -0
- package/README.md +94 -0
- package/bin/claude-coach.js +10 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +1077 -0
- package/dist/db/client.d.ts +8 -0
- package/dist/db/client.js +111 -0
- package/dist/db/migrate.d.ts +1 -0
- package/dist/db/migrate.js +14 -0
- package/dist/db/schema.sql +105 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +13 -0
- package/dist/lib/config.d.ts +27 -0
- package/dist/lib/config.js +86 -0
- package/dist/lib/logging.d.ts +13 -0
- package/dist/lib/logging.js +28 -0
- package/dist/schema/training-plan.d.ts +288 -0
- package/dist/schema/training-plan.js +88 -0
- package/dist/schema/training-plan.schema.d.ts +1875 -0
- package/dist/schema/training-plan.schema.js +418 -0
- package/dist/strava/api.d.ts +5 -0
- package/dist/strava/api.js +63 -0
- package/dist/strava/oauth.d.ts +4 -0
- package/dist/strava/oauth.js +113 -0
- package/dist/strava/types.d.ts +46 -0
- package/dist/strava/types.js +1 -0
- package/dist/viewer/lib/UpdatePlan.d.ts +48 -0
- package/dist/viewer/lib/UpdatePlan.js +209 -0
- package/dist/viewer/lib/export/erg.d.ts +26 -0
- package/dist/viewer/lib/export/erg.js +208 -0
- package/dist/viewer/lib/export/fit.d.ts +25 -0
- package/dist/viewer/lib/export/fit.js +308 -0
- package/dist/viewer/lib/export/ics.d.ts +13 -0
- package/dist/viewer/lib/export/ics.js +142 -0
- package/dist/viewer/lib/export/index.d.ts +50 -0
- package/dist/viewer/lib/export/index.js +229 -0
- package/dist/viewer/lib/export/zwo.d.ts +21 -0
- package/dist/viewer/lib/export/zwo.js +233 -0
- package/dist/viewer/lib/utils.d.ts +14 -0
- package/dist/viewer/lib/utils.js +123 -0
- package/dist/viewer/main.d.ts +5 -0
- package/dist/viewer/main.js +6 -0
- package/dist/viewer/stores/changes.d.ts +21 -0
- package/dist/viewer/stores/changes.js +49 -0
- package/dist/viewer/stores/plan.d.ts +11 -0
- package/dist/viewer/stores/plan.js +40 -0
- package/dist/viewer/stores/settings.d.ts +53 -0
- package/dist/viewer/stores/settings.js +215 -0
- package/package.json +74 -0
- package/templates/plan-viewer.html +70 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Training Plan Zod Schema
|
|
3
|
+
*
|
|
4
|
+
* Runtime validation schema that mirrors the TypeScript interfaces.
|
|
5
|
+
* Use this to validate JSON plans before rendering to HTML.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Core Types
|
|
10
|
+
// ============================================================================
|
|
11
|
+
export const SportSchema = z.enum(["swim", "bike", "run", "strength", "brick", "race", "rest"]);
|
|
12
|
+
// Workout types - flexible to support various training methodologies
|
|
13
|
+
// Common types: rest, recovery, easy, endurance, tempo, threshold, intervals,
|
|
14
|
+
// vo2max, sprint, speed, race, brick, strength, technique, openwater, hills, long
|
|
15
|
+
export const WorkoutTypeSchema = z.string();
|
|
16
|
+
export const IntensityUnitSchema = z.enum([
|
|
17
|
+
"percent_ftp",
|
|
18
|
+
"percent_lthr",
|
|
19
|
+
"hr_zone",
|
|
20
|
+
"pace_zone",
|
|
21
|
+
"rpe",
|
|
22
|
+
"css_offset",
|
|
23
|
+
]);
|
|
24
|
+
export const DurationUnitSchema = z.enum([
|
|
25
|
+
"seconds",
|
|
26
|
+
"minutes",
|
|
27
|
+
"hours",
|
|
28
|
+
"meters",
|
|
29
|
+
"kilometers",
|
|
30
|
+
"miles",
|
|
31
|
+
"yards",
|
|
32
|
+
"laps",
|
|
33
|
+
]);
|
|
34
|
+
export const StepTypeSchema = z.enum([
|
|
35
|
+
"warmup",
|
|
36
|
+
"work",
|
|
37
|
+
"recovery",
|
|
38
|
+
"rest",
|
|
39
|
+
"cooldown",
|
|
40
|
+
"interval_set",
|
|
41
|
+
]);
|
|
42
|
+
// Unit system preferences
|
|
43
|
+
export const SwimDistanceUnitSchema = z.enum(["meters", "yards"]);
|
|
44
|
+
export const LandDistanceUnitSchema = z.enum(["kilometers", "miles"]);
|
|
45
|
+
export const FirstDayOfWeekSchema = z.enum(["monday", "sunday"]);
|
|
46
|
+
export const UnitPreferencesSchema = z.object({
|
|
47
|
+
swim: SwimDistanceUnitSchema,
|
|
48
|
+
bike: LandDistanceUnitSchema,
|
|
49
|
+
run: LandDistanceUnitSchema,
|
|
50
|
+
firstDayOfWeek: FirstDayOfWeekSchema,
|
|
51
|
+
});
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Workout Structure (for Zwift/Garmin export)
|
|
54
|
+
// ============================================================================
|
|
55
|
+
export const IntensityTargetSchema = z.object({
|
|
56
|
+
unit: IntensityUnitSchema,
|
|
57
|
+
value: z.number(),
|
|
58
|
+
valueLow: z.number().optional(),
|
|
59
|
+
valueHigh: z.number().optional(),
|
|
60
|
+
description: z.string().optional(),
|
|
61
|
+
});
|
|
62
|
+
export const DurationTargetSchema = z.object({
|
|
63
|
+
unit: DurationUnitSchema,
|
|
64
|
+
value: z.number(),
|
|
65
|
+
});
|
|
66
|
+
export const CadenceSchema = z.object({
|
|
67
|
+
low: z.number(),
|
|
68
|
+
high: z.number(),
|
|
69
|
+
});
|
|
70
|
+
export const WorkoutStepSchema = z.object({
|
|
71
|
+
type: StepTypeSchema,
|
|
72
|
+
name: z.string().optional(),
|
|
73
|
+
duration: DurationTargetSchema,
|
|
74
|
+
intensity: IntensityTargetSchema,
|
|
75
|
+
cadence: CadenceSchema.optional(),
|
|
76
|
+
notes: z.string().optional(),
|
|
77
|
+
});
|
|
78
|
+
export const IntervalSetSchema = z.object({
|
|
79
|
+
type: z.literal("interval_set"),
|
|
80
|
+
name: z.string().optional(),
|
|
81
|
+
repeats: z.number().int().positive(),
|
|
82
|
+
steps: z.array(WorkoutStepSchema),
|
|
83
|
+
});
|
|
84
|
+
export const StructuredWorkoutSchema = z.object({
|
|
85
|
+
warmup: z.array(WorkoutStepSchema).optional(),
|
|
86
|
+
main: z.array(z.union([WorkoutStepSchema, IntervalSetSchema])),
|
|
87
|
+
cooldown: z.array(WorkoutStepSchema).optional(),
|
|
88
|
+
totalDuration: DurationTargetSchema.optional(),
|
|
89
|
+
estimatedTSS: z.number().optional(),
|
|
90
|
+
estimatedIF: z.number().optional(),
|
|
91
|
+
});
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// Daily Workout
|
|
94
|
+
// ============================================================================
|
|
95
|
+
export const HRRangeSchema = z.object({
|
|
96
|
+
low: z.number(),
|
|
97
|
+
high: z.number(),
|
|
98
|
+
});
|
|
99
|
+
export const PowerRangeSchema = z.union([
|
|
100
|
+
z.object({
|
|
101
|
+
low: z.number(),
|
|
102
|
+
high: z.number(),
|
|
103
|
+
}),
|
|
104
|
+
z.string(), // Sometimes just a string like "200W"
|
|
105
|
+
]);
|
|
106
|
+
export const PaceRangeSchema = z.object({
|
|
107
|
+
low: z.string(),
|
|
108
|
+
high: z.string(),
|
|
109
|
+
});
|
|
110
|
+
export const WorkoutSchema = z.object({
|
|
111
|
+
id: z.string(),
|
|
112
|
+
sport: SportSchema,
|
|
113
|
+
type: WorkoutTypeSchema,
|
|
114
|
+
name: z.string(),
|
|
115
|
+
description: z.string().optional(), // Optional - humanReadable can serve same purpose
|
|
116
|
+
// Duration
|
|
117
|
+
durationMinutes: z.number().optional(),
|
|
118
|
+
distanceMeters: z.number().optional(),
|
|
119
|
+
distanceKm: z.number().optional(), // Alternative for bike/run
|
|
120
|
+
// Intensity summary
|
|
121
|
+
primaryZone: z.string().optional(),
|
|
122
|
+
targetHR: HRRangeSchema.optional(),
|
|
123
|
+
targetPower: PowerRangeSchema.optional(),
|
|
124
|
+
targetPace: PaceRangeSchema.optional(),
|
|
125
|
+
rpe: z.number().min(1).max(10).optional(),
|
|
126
|
+
// Structured workout for device export
|
|
127
|
+
structure: StructuredWorkoutSchema.optional(),
|
|
128
|
+
// Human-readable workout text
|
|
129
|
+
humanReadable: z.string().optional(),
|
|
130
|
+
// Tracking
|
|
131
|
+
completed: z.boolean(),
|
|
132
|
+
completedAt: z.string().optional(),
|
|
133
|
+
actualDuration: z.number().optional(),
|
|
134
|
+
actualDistance: z.number().optional(),
|
|
135
|
+
notes: z.string().optional(),
|
|
136
|
+
});
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// Training Week
|
|
139
|
+
// ============================================================================
|
|
140
|
+
export const TrainingDaySchema = z.object({
|
|
141
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in ISO format: YYYY-MM-DD"),
|
|
142
|
+
dayOfWeek: z.string(),
|
|
143
|
+
workouts: z.array(WorkoutSchema),
|
|
144
|
+
});
|
|
145
|
+
export const SportSummarySchema = z.object({
|
|
146
|
+
sessions: z.number(),
|
|
147
|
+
hours: z.number().optional(), // Sometimes missing for race day
|
|
148
|
+
km: z.union([z.number(), z.string()]).optional(), // Sometimes a string
|
|
149
|
+
meters: z.union([z.number(), z.string()]).optional(), // For swim
|
|
150
|
+
});
|
|
151
|
+
export const WeekSummarySchema = z.object({
|
|
152
|
+
totalHours: z.number(),
|
|
153
|
+
totalTSS: z.number().optional(),
|
|
154
|
+
// Partial record - only sports with sessions are included
|
|
155
|
+
bySport: z.record(z.string(), SportSummarySchema).optional(),
|
|
156
|
+
});
|
|
157
|
+
export const TrainingWeekSchema = z.object({
|
|
158
|
+
weekNumber: z.number().int().positive(),
|
|
159
|
+
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in ISO format: YYYY-MM-DD"),
|
|
160
|
+
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in ISO format: YYYY-MM-DD"),
|
|
161
|
+
phase: z.string(),
|
|
162
|
+
focus: z.string(),
|
|
163
|
+
targetHours: z.number(),
|
|
164
|
+
days: z.array(TrainingDaySchema),
|
|
165
|
+
summary: WeekSummarySchema,
|
|
166
|
+
isRecoveryWeek: z.boolean(),
|
|
167
|
+
});
|
|
168
|
+
// ============================================================================
|
|
169
|
+
// Training Zones
|
|
170
|
+
// ============================================================================
|
|
171
|
+
export const HeartRateZoneSchema = z.object({
|
|
172
|
+
zone: z.number().int(),
|
|
173
|
+
name: z.string(),
|
|
174
|
+
percentLow: z.number().optional(),
|
|
175
|
+
percentHigh: z.number().optional(),
|
|
176
|
+
hrLow: z.number(),
|
|
177
|
+
hrHigh: z.number(),
|
|
178
|
+
});
|
|
179
|
+
export const HeartRateZonesSchema = z.object({
|
|
180
|
+
lthr: z.number(),
|
|
181
|
+
zones: z.array(HeartRateZoneSchema),
|
|
182
|
+
});
|
|
183
|
+
export const PowerZoneSchema = z.object({
|
|
184
|
+
zone: z.number().int(),
|
|
185
|
+
name: z.string(),
|
|
186
|
+
percentLow: z.number(),
|
|
187
|
+
percentHigh: z.number(),
|
|
188
|
+
wattsLow: z.number(),
|
|
189
|
+
wattsHigh: z.number(),
|
|
190
|
+
});
|
|
191
|
+
export const PowerZonesSchema = z.object({
|
|
192
|
+
ftp: z.number(),
|
|
193
|
+
zones: z.array(PowerZoneSchema),
|
|
194
|
+
});
|
|
195
|
+
export const SwimZoneSchema = z.object({
|
|
196
|
+
zone: z.number().int(),
|
|
197
|
+
name: z.string(),
|
|
198
|
+
paceOffset: z.number(),
|
|
199
|
+
pace: z.string(),
|
|
200
|
+
});
|
|
201
|
+
export const SwimZonesSchema = z.object({
|
|
202
|
+
css: z.string(),
|
|
203
|
+
cssSeconds: z.number(),
|
|
204
|
+
zones: z.array(SwimZoneSchema),
|
|
205
|
+
});
|
|
206
|
+
export const PaceZoneSchema = z.object({
|
|
207
|
+
zone: z.union([z.number(), z.string()]), // Can be number (1, 2, 3) or string ("E", "M", "T")
|
|
208
|
+
name: z.string(),
|
|
209
|
+
pace: z.string(),
|
|
210
|
+
paceOffset: z.number().optional(), // Offset from threshold
|
|
211
|
+
paceSeconds: z.number().optional(), // Absolute pace in seconds
|
|
212
|
+
});
|
|
213
|
+
export const PaceZonesSchema = z.object({
|
|
214
|
+
// Support both naming conventions
|
|
215
|
+
threshold: z.string().optional(),
|
|
216
|
+
thresholdPace: z.string().optional(),
|
|
217
|
+
thresholdSeconds: z.number().optional(),
|
|
218
|
+
thresholdPaceSeconds: z.number().optional(),
|
|
219
|
+
zones: z.array(PaceZoneSchema).optional(), // Optional - some plans don't have zones defined
|
|
220
|
+
});
|
|
221
|
+
export const AthleteZonesSchema = z.object({
|
|
222
|
+
run: z
|
|
223
|
+
.object({
|
|
224
|
+
hr: HeartRateZonesSchema.optional(),
|
|
225
|
+
pace: PaceZonesSchema.optional(),
|
|
226
|
+
})
|
|
227
|
+
.optional(),
|
|
228
|
+
bike: z
|
|
229
|
+
.object({
|
|
230
|
+
hr: HeartRateZonesSchema.optional(),
|
|
231
|
+
power: PowerZonesSchema.optional(),
|
|
232
|
+
})
|
|
233
|
+
.optional(),
|
|
234
|
+
swim: SwimZonesSchema.optional(),
|
|
235
|
+
maxHR: z.number().optional(),
|
|
236
|
+
restingHR: z.number().optional(),
|
|
237
|
+
weight: z.number().optional(),
|
|
238
|
+
});
|
|
239
|
+
// ============================================================================
|
|
240
|
+
// Athlete Assessment
|
|
241
|
+
// ============================================================================
|
|
242
|
+
// Foundation level - flexible to support various descriptors
|
|
243
|
+
export const FoundationLevelSchema = z.string();
|
|
244
|
+
export const FoundationSchema = z.object({
|
|
245
|
+
raceHistory: z.array(z.string()),
|
|
246
|
+
peakTrainingLoad: z.number(),
|
|
247
|
+
foundationLevel: FoundationLevelSchema,
|
|
248
|
+
yearsInSport: z.number(),
|
|
249
|
+
});
|
|
250
|
+
export const WeeklyVolumeSchema = z.object({
|
|
251
|
+
total: z.number(),
|
|
252
|
+
swim: z.number().optional(),
|
|
253
|
+
bike: z.number().optional(),
|
|
254
|
+
run: z.number().optional(),
|
|
255
|
+
});
|
|
256
|
+
export const LongestSessionsSchema = z.object({
|
|
257
|
+
swim: z.number().optional(),
|
|
258
|
+
bike: z.number().optional(),
|
|
259
|
+
run: z.number().optional(),
|
|
260
|
+
});
|
|
261
|
+
export const CurrentFormSchema = z.object({
|
|
262
|
+
weeklyVolume: WeeklyVolumeSchema,
|
|
263
|
+
longestSessions: LongestSessionsSchema,
|
|
264
|
+
consistency: z.number(),
|
|
265
|
+
timeSincePeakFitness: z.string().optional(),
|
|
266
|
+
reasonForTimeOff: z.string().optional(),
|
|
267
|
+
});
|
|
268
|
+
export const SportEvidenceSchema = z.object({
|
|
269
|
+
sport: SportSchema,
|
|
270
|
+
evidence: z.string(),
|
|
271
|
+
});
|
|
272
|
+
export const AthleteAssessmentSchema = z.object({
|
|
273
|
+
foundation: FoundationSchema,
|
|
274
|
+
currentForm: CurrentFormSchema,
|
|
275
|
+
strengths: z.array(SportEvidenceSchema),
|
|
276
|
+
limiters: z.array(SportEvidenceSchema),
|
|
277
|
+
constraints: z.array(z.string()),
|
|
278
|
+
});
|
|
279
|
+
// ============================================================================
|
|
280
|
+
// Training Phases
|
|
281
|
+
// ============================================================================
|
|
282
|
+
export const WeeklyHoursRangeSchema = z.object({
|
|
283
|
+
low: z.number(),
|
|
284
|
+
high: z.number(),
|
|
285
|
+
});
|
|
286
|
+
export const TrainingPhaseSchema = z.object({
|
|
287
|
+
name: z.string(),
|
|
288
|
+
startWeek: z.number().int().positive(),
|
|
289
|
+
endWeek: z.number().int().positive(),
|
|
290
|
+
focus: z.string(),
|
|
291
|
+
weeklyHoursRange: WeeklyHoursRangeSchema,
|
|
292
|
+
keyWorkouts: z.array(z.string()),
|
|
293
|
+
physiologicalGoals: z.array(z.string()),
|
|
294
|
+
});
|
|
295
|
+
// ============================================================================
|
|
296
|
+
// Race Strategy
|
|
297
|
+
// ============================================================================
|
|
298
|
+
export const EventDistancesSchema = z.object({
|
|
299
|
+
swim: z.number().optional(),
|
|
300
|
+
bike: z.number().optional(),
|
|
301
|
+
run: z.number().optional(),
|
|
302
|
+
});
|
|
303
|
+
export const RaceEventSchema = z.object({
|
|
304
|
+
name: z.string(),
|
|
305
|
+
date: z.string(),
|
|
306
|
+
type: z.string(),
|
|
307
|
+
distances: EventDistancesSchema.optional(),
|
|
308
|
+
});
|
|
309
|
+
// Pacing schemas - very flexible to support various race formats
|
|
310
|
+
export const SwimPacingSchema = z.record(z.string(), z.any());
|
|
311
|
+
export const BikePacingSchema = z.record(z.string(), z.any());
|
|
312
|
+
export const RunPacingSchema = z.record(z.string(), z.any());
|
|
313
|
+
export const RacePacingSchema = z.record(z.string(), z.any());
|
|
314
|
+
// Nutrition - flexible to support simple or complex structures
|
|
315
|
+
export const RaceNutritionSchema = z.record(z.string(), z.any());
|
|
316
|
+
// Taper - flexible structure
|
|
317
|
+
export const TaperSchema = z.record(z.string(), z.any());
|
|
318
|
+
// Race day - flexible structure (can have strings or objects)
|
|
319
|
+
export const RaceDaySchema = z.record(z.string(), z.any());
|
|
320
|
+
// Race strategy - flexible to support various race formats
|
|
321
|
+
export const RaceStrategySchema = z.record(z.string(), z.any());
|
|
322
|
+
// ============================================================================
|
|
323
|
+
// Plan Metadata
|
|
324
|
+
// ============================================================================
|
|
325
|
+
export const PlanMetaSchema = z.object({
|
|
326
|
+
id: z.string(),
|
|
327
|
+
athlete: z.string(),
|
|
328
|
+
event: z.string(),
|
|
329
|
+
eventDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in ISO format: YYYY-MM-DD"),
|
|
330
|
+
planStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in ISO format: YYYY-MM-DD"),
|
|
331
|
+
planEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in ISO format: YYYY-MM-DD"),
|
|
332
|
+
createdAt: z.string(),
|
|
333
|
+
updatedAt: z.string(),
|
|
334
|
+
totalWeeks: z.number().int().positive(),
|
|
335
|
+
generatedBy: z.string(),
|
|
336
|
+
});
|
|
337
|
+
// ============================================================================
|
|
338
|
+
// Complete Training Plan
|
|
339
|
+
// ============================================================================
|
|
340
|
+
export const TrainingPlanSchema = z.object({
|
|
341
|
+
version: z.literal("1.0"),
|
|
342
|
+
meta: PlanMetaSchema,
|
|
343
|
+
preferences: UnitPreferencesSchema,
|
|
344
|
+
assessment: AthleteAssessmentSchema,
|
|
345
|
+
zones: AthleteZonesSchema,
|
|
346
|
+
phases: z.array(TrainingPhaseSchema),
|
|
347
|
+
weeks: z.array(TrainingWeekSchema),
|
|
348
|
+
raceStrategy: RaceStrategySchema,
|
|
349
|
+
});
|
|
350
|
+
/**
|
|
351
|
+
* Validate a training plan JSON against the schema.
|
|
352
|
+
* Returns a result object with either the validated data or an array of errors.
|
|
353
|
+
*/
|
|
354
|
+
export function validatePlan(data) {
|
|
355
|
+
const result = TrainingPlanSchema.safeParse(data);
|
|
356
|
+
if (result.success) {
|
|
357
|
+
return { success: true, data: result.data };
|
|
358
|
+
}
|
|
359
|
+
const errors = result.error.issues.map((issue) => ({
|
|
360
|
+
path: issue.path.join("."),
|
|
361
|
+
message: issue.message,
|
|
362
|
+
code: issue.code,
|
|
363
|
+
}));
|
|
364
|
+
return { success: false, errors };
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Validate a training plan and throw an error if invalid.
|
|
368
|
+
* Use this when you want to halt execution on validation failure.
|
|
369
|
+
*/
|
|
370
|
+
export function validatePlanOrThrow(data) {
|
|
371
|
+
return TrainingPlanSchema.parse(data);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Format validation errors into a human-readable string.
|
|
375
|
+
*/
|
|
376
|
+
export function formatValidationErrors(errors) {
|
|
377
|
+
if (errors.length === 0)
|
|
378
|
+
return "No errors";
|
|
379
|
+
const lines = errors.map((e, i) => {
|
|
380
|
+
const path = e.path || "(root)";
|
|
381
|
+
return ` ${i + 1}. ${path}: ${e.message}`;
|
|
382
|
+
});
|
|
383
|
+
return `Validation failed with ${errors.length} error(s):\n${lines.join("\n")}`;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Get the JSON Schema representation of the training plan schema.
|
|
387
|
+
* Useful for documentation and external validation tools.
|
|
388
|
+
*/
|
|
389
|
+
export function getJsonSchema() {
|
|
390
|
+
// Note: For full JSON Schema generation, consider using zod-to-json-schema
|
|
391
|
+
// This returns a simplified representation
|
|
392
|
+
return {
|
|
393
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
394
|
+
title: "TrainingPlan",
|
|
395
|
+
description: "Endurance Coach training plan schema v1.0",
|
|
396
|
+
type: "object",
|
|
397
|
+
required: [
|
|
398
|
+
"version",
|
|
399
|
+
"meta",
|
|
400
|
+
"preferences",
|
|
401
|
+
"assessment",
|
|
402
|
+
"zones",
|
|
403
|
+
"phases",
|
|
404
|
+
"weeks",
|
|
405
|
+
"raceStrategy",
|
|
406
|
+
],
|
|
407
|
+
properties: {
|
|
408
|
+
version: { const: "1.0" },
|
|
409
|
+
meta: { $ref: "#/definitions/PlanMeta" },
|
|
410
|
+
preferences: { $ref: "#/definitions/UnitPreferences" },
|
|
411
|
+
assessment: { $ref: "#/definitions/AthleteAssessment" },
|
|
412
|
+
zones: { $ref: "#/definitions/AthleteZones" },
|
|
413
|
+
phases: { type: "array", items: { $ref: "#/definitions/TrainingPhase" } },
|
|
414
|
+
weeks: { type: "array", items: { $ref: "#/definitions/TrainingWeek" } },
|
|
415
|
+
raceStrategy: { $ref: "#/definitions/RaceStrategy" },
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Tokens } from "../lib/config.js";
|
|
2
|
+
import type { StravaActivity, StravaAthlete } from "./types.js";
|
|
3
|
+
export declare function getAthlete(tokens: Tokens): Promise<StravaAthlete>;
|
|
4
|
+
export declare function getActivities(tokens: Tokens, after: number, before?: number, page?: number, perPage?: number): Promise<StravaActivity[]>;
|
|
5
|
+
export declare function getAllActivities(tokens: Tokens, afterDate: Date): Promise<StravaActivity[]>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { log } from "../lib/logging.js";
|
|
2
|
+
const API_BASE = "https://www.strava.com/api/v3";
|
|
3
|
+
async function fetchWithRetry(url, options, retries = 3) {
|
|
4
|
+
const response = await fetch(url, options);
|
|
5
|
+
if (response.status === 429) {
|
|
6
|
+
const retryAfter = parseInt(response.headers.get("retry-after") || "60");
|
|
7
|
+
log.warn(`Rate limited. Waiting ${retryAfter}s...`);
|
|
8
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
|
|
9
|
+
return fetchWithRetry(url, options, retries);
|
|
10
|
+
}
|
|
11
|
+
if (!response.ok && retries > 0) {
|
|
12
|
+
log.warn(`Request failed (${response.status}), retrying...`);
|
|
13
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
14
|
+
return fetchWithRetry(url, options, retries - 1);
|
|
15
|
+
}
|
|
16
|
+
return response;
|
|
17
|
+
}
|
|
18
|
+
export async function getAthlete(tokens) {
|
|
19
|
+
const response = await fetchWithRetry(`${API_BASE}/athlete`, {
|
|
20
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error(`Failed to fetch athlete: ${response.statusText}`);
|
|
24
|
+
}
|
|
25
|
+
return response.json();
|
|
26
|
+
}
|
|
27
|
+
export async function getActivities(tokens, after, before, page = 1, perPage = 100) {
|
|
28
|
+
const url = new URL(`${API_BASE}/athlete/activities`);
|
|
29
|
+
url.searchParams.set("after", after.toString());
|
|
30
|
+
if (before) {
|
|
31
|
+
url.searchParams.set("before", before.toString());
|
|
32
|
+
}
|
|
33
|
+
url.searchParams.set("page", page.toString());
|
|
34
|
+
url.searchParams.set("per_page", perPage.toString());
|
|
35
|
+
const response = await fetchWithRetry(url.toString(), {
|
|
36
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new Error(`Failed to fetch activities: ${response.statusText}`);
|
|
40
|
+
}
|
|
41
|
+
return response.json();
|
|
42
|
+
}
|
|
43
|
+
export async function getAllActivities(tokens, afterDate) {
|
|
44
|
+
const after = Math.floor(afterDate.getTime() / 1000);
|
|
45
|
+
const activities = [];
|
|
46
|
+
let page = 1;
|
|
47
|
+
const perPage = 100;
|
|
48
|
+
log.start(`Fetching activities since ${afterDate.toISOString().split("T")[0]}...`);
|
|
49
|
+
while (true) {
|
|
50
|
+
const batch = await getActivities(tokens, after, undefined, page, perPage);
|
|
51
|
+
activities.push(...batch);
|
|
52
|
+
log.progress(` Fetched ${activities.length} activities...`);
|
|
53
|
+
if (batch.length < perPage) {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
page++;
|
|
57
|
+
// Small delay to be nice to the API
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
59
|
+
}
|
|
60
|
+
log.progressEnd();
|
|
61
|
+
log.success(`Fetched ${activities.length} activities total`);
|
|
62
|
+
return activities;
|
|
63
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createServer } from "http";
|
|
2
|
+
import { URL } from "url";
|
|
3
|
+
import open from "open";
|
|
4
|
+
import { loadConfig, loadTokens, saveTokens, tokensExist, tokensExpired, } from "../lib/config.js";
|
|
5
|
+
import { log } from "../lib/logging.js";
|
|
6
|
+
const REDIRECT_PORT = 8765;
|
|
7
|
+
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
|
|
8
|
+
const AUTHORIZE_URL = "https://www.strava.com/oauth/authorize";
|
|
9
|
+
const TOKEN_URL = "https://www.strava.com/oauth/token";
|
|
10
|
+
export async function authorize() {
|
|
11
|
+
const config = loadConfig();
|
|
12
|
+
const { client_id, client_secret } = config.strava;
|
|
13
|
+
const authUrl = new URL(AUTHORIZE_URL);
|
|
14
|
+
authUrl.searchParams.set("client_id", client_id);
|
|
15
|
+
authUrl.searchParams.set("response_type", "code");
|
|
16
|
+
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
17
|
+
authUrl.searchParams.set("scope", "activity:read_all");
|
|
18
|
+
authUrl.searchParams.set("approval_prompt", "auto");
|
|
19
|
+
log.info("Opening browser for Strava authorization...");
|
|
20
|
+
const code = await new Promise((resolve, reject) => {
|
|
21
|
+
const server = createServer((req, res) => {
|
|
22
|
+
const url = new URL(req.url, `http://localhost:${REDIRECT_PORT}`);
|
|
23
|
+
if (url.pathname === "/callback") {
|
|
24
|
+
const code = url.searchParams.get("code");
|
|
25
|
+
const error = url.searchParams.get("error");
|
|
26
|
+
if (error) {
|
|
27
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
28
|
+
res.end(`<h1>Authorization Failed</h1><p>${error}</p>`);
|
|
29
|
+
server.close();
|
|
30
|
+
reject(new Error(`Authorization failed: ${error}`));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (code) {
|
|
34
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
35
|
+
res.end("<h1>✅ Authorization Successful!</h1><p>You can close this window.</p>");
|
|
36
|
+
server.close();
|
|
37
|
+
resolve(code);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
server.listen(REDIRECT_PORT, () => {
|
|
42
|
+
open(authUrl.toString());
|
|
43
|
+
});
|
|
44
|
+
server.on("error", (err) => {
|
|
45
|
+
reject(new Error(`Failed to start callback server: ${err.message}`));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
log.success("Authorization code received, exchanging for tokens...");
|
|
49
|
+
const tokenResponse = await fetch(TOKEN_URL, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
client_id,
|
|
54
|
+
client_secret,
|
|
55
|
+
code,
|
|
56
|
+
grant_type: "authorization_code",
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
if (!tokenResponse.ok) {
|
|
60
|
+
const error = await tokenResponse.text();
|
|
61
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
62
|
+
}
|
|
63
|
+
const data = await tokenResponse.json();
|
|
64
|
+
const tokens = {
|
|
65
|
+
access_token: data.access_token,
|
|
66
|
+
refresh_token: data.refresh_token,
|
|
67
|
+
expires_at: data.expires_at,
|
|
68
|
+
athlete_id: data.athlete.id,
|
|
69
|
+
};
|
|
70
|
+
saveTokens(tokens);
|
|
71
|
+
log.success(`Authenticated as ${data.athlete.firstname} ${data.athlete.lastname}`);
|
|
72
|
+
return tokens;
|
|
73
|
+
}
|
|
74
|
+
export async function refreshTokens() {
|
|
75
|
+
const config = loadConfig();
|
|
76
|
+
const oldTokens = loadTokens();
|
|
77
|
+
const { client_id, client_secret } = config.strava;
|
|
78
|
+
log.start("Refreshing access token...");
|
|
79
|
+
const response = await fetch(TOKEN_URL, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
client_id,
|
|
84
|
+
client_secret,
|
|
85
|
+
refresh_token: oldTokens.refresh_token,
|
|
86
|
+
grant_type: "refresh_token",
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const error = await response.text();
|
|
91
|
+
throw new Error(`Token refresh failed: ${error}`);
|
|
92
|
+
}
|
|
93
|
+
const data = await response.json();
|
|
94
|
+
const tokens = {
|
|
95
|
+
access_token: data.access_token,
|
|
96
|
+
refresh_token: data.refresh_token,
|
|
97
|
+
expires_at: data.expires_at,
|
|
98
|
+
athlete_id: oldTokens.athlete_id,
|
|
99
|
+
};
|
|
100
|
+
saveTokens(tokens);
|
|
101
|
+
log.success("Token refreshed");
|
|
102
|
+
return tokens;
|
|
103
|
+
}
|
|
104
|
+
export async function getValidTokens() {
|
|
105
|
+
if (!tokensExist()) {
|
|
106
|
+
return authorize();
|
|
107
|
+
}
|
|
108
|
+
const tokens = loadTokens();
|
|
109
|
+
if (tokensExpired(tokens)) {
|
|
110
|
+
return refreshTokens();
|
|
111
|
+
}
|
|
112
|
+
return tokens;
|
|
113
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface StravaAthlete {
|
|
2
|
+
id: number;
|
|
3
|
+
firstname: string;
|
|
4
|
+
lastname: string;
|
|
5
|
+
weight?: number;
|
|
6
|
+
ftp?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface StravaTokenResponse {
|
|
9
|
+
token_type: string;
|
|
10
|
+
access_token: string;
|
|
11
|
+
refresh_token: string;
|
|
12
|
+
expires_at: number;
|
|
13
|
+
expires_in: number;
|
|
14
|
+
athlete: StravaAthlete;
|
|
15
|
+
}
|
|
16
|
+
export interface StravaActivity {
|
|
17
|
+
id: number;
|
|
18
|
+
name: string;
|
|
19
|
+
sport_type: string;
|
|
20
|
+
start_date: string;
|
|
21
|
+
elapsed_time: number;
|
|
22
|
+
moving_time: number;
|
|
23
|
+
distance: number;
|
|
24
|
+
total_elevation_gain: number;
|
|
25
|
+
average_speed: number;
|
|
26
|
+
max_speed: number;
|
|
27
|
+
average_heartrate?: number;
|
|
28
|
+
max_heartrate?: number;
|
|
29
|
+
average_watts?: number;
|
|
30
|
+
max_watts?: number;
|
|
31
|
+
weighted_average_watts?: number;
|
|
32
|
+
kilojoules?: number;
|
|
33
|
+
suffer_score?: number;
|
|
34
|
+
average_cadence?: number;
|
|
35
|
+
calories?: number;
|
|
36
|
+
description?: string;
|
|
37
|
+
workout_type?: number;
|
|
38
|
+
gear_id?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface StravaStream {
|
|
41
|
+
type: string;
|
|
42
|
+
data: number[];
|
|
43
|
+
series_type: string;
|
|
44
|
+
original_size: number;
|
|
45
|
+
resolution: string;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|