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.
Files changed (50) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +94 -0
  3. package/bin/claude-coach.js +10 -0
  4. package/dist/cli.d.ts +6 -0
  5. package/dist/cli.js +1077 -0
  6. package/dist/db/client.d.ts +8 -0
  7. package/dist/db/client.js +111 -0
  8. package/dist/db/migrate.d.ts +1 -0
  9. package/dist/db/migrate.js +14 -0
  10. package/dist/db/schema.sql +105 -0
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.js +13 -0
  13. package/dist/lib/config.d.ts +27 -0
  14. package/dist/lib/config.js +86 -0
  15. package/dist/lib/logging.d.ts +13 -0
  16. package/dist/lib/logging.js +28 -0
  17. package/dist/schema/training-plan.d.ts +288 -0
  18. package/dist/schema/training-plan.js +88 -0
  19. package/dist/schema/training-plan.schema.d.ts +1875 -0
  20. package/dist/schema/training-plan.schema.js +418 -0
  21. package/dist/strava/api.d.ts +5 -0
  22. package/dist/strava/api.js +63 -0
  23. package/dist/strava/oauth.d.ts +4 -0
  24. package/dist/strava/oauth.js +113 -0
  25. package/dist/strava/types.d.ts +46 -0
  26. package/dist/strava/types.js +1 -0
  27. package/dist/viewer/lib/UpdatePlan.d.ts +48 -0
  28. package/dist/viewer/lib/UpdatePlan.js +209 -0
  29. package/dist/viewer/lib/export/erg.d.ts +26 -0
  30. package/dist/viewer/lib/export/erg.js +208 -0
  31. package/dist/viewer/lib/export/fit.d.ts +25 -0
  32. package/dist/viewer/lib/export/fit.js +308 -0
  33. package/dist/viewer/lib/export/ics.d.ts +13 -0
  34. package/dist/viewer/lib/export/ics.js +142 -0
  35. package/dist/viewer/lib/export/index.d.ts +50 -0
  36. package/dist/viewer/lib/export/index.js +229 -0
  37. package/dist/viewer/lib/export/zwo.d.ts +21 -0
  38. package/dist/viewer/lib/export/zwo.js +233 -0
  39. package/dist/viewer/lib/utils.d.ts +14 -0
  40. package/dist/viewer/lib/utils.js +123 -0
  41. package/dist/viewer/main.d.ts +5 -0
  42. package/dist/viewer/main.js +6 -0
  43. package/dist/viewer/stores/changes.d.ts +21 -0
  44. package/dist/viewer/stores/changes.js +49 -0
  45. package/dist/viewer/stores/plan.d.ts +11 -0
  46. package/dist/viewer/stores/plan.js +40 -0
  47. package/dist/viewer/stores/settings.d.ts +53 -0
  48. package/dist/viewer/stores/settings.js +215 -0
  49. package/package.json +74 -0
  50. 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,4 @@
1
+ import { type Tokens } from "../lib/config.js";
2
+ export declare function authorize(): Promise<Tokens>;
3
+ export declare function refreshTokens(): Promise<Tokens>;
4
+ export declare function getValidTokens(): Promise<Tokens>;
@@ -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 {};