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
package/dist/cli.js ADDED
@@ -0,0 +1,1077 @@
1
+ import { configExists, loadConfig, promptForConfig, saveConfig, saveTokens, getDbPath, createConfig, } from "./lib/config.js";
2
+ import { log } from "./lib/logging.js";
3
+ import { migrate } from "./db/migrate.js";
4
+ import { execute, initDatabase, query, queryJson } from "./db/client.js";
5
+ import { getValidTokens } from "./strava/oauth.js";
6
+ import { getAllActivities, getAthlete } from "./strava/api.js";
7
+ import { readFileSync, writeFileSync } from "fs";
8
+ import { dirname, join } from "path";
9
+ import { fileURLToPath } from "url";
10
+ import { ProxyAgent, setGlobalDispatcher } from "undici";
11
+ import { validatePlan, formatValidationErrors } from "./schema/training-plan.schema.js";
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ // ============================================================================
14
+ // Proxy Configuration
15
+ // ============================================================================
16
+ // Configure proxy for fetch() if HTTP_PROXY or HTTPS_PROXY is set
17
+ const proxyUrl = process.env.HTTPS_PROXY ||
18
+ process.env.https_proxy ||
19
+ process.env.HTTP_PROXY ||
20
+ process.env.http_proxy;
21
+ if (proxyUrl) {
22
+ setGlobalDispatcher(new ProxyAgent(proxyUrl));
23
+ }
24
+ function parseArgs() {
25
+ const args = process.argv.slice(2);
26
+ if (args.length === 0 || args[0] === "sync") {
27
+ // Sync command (default)
28
+ const syncArgs = { command: "sync" };
29
+ for (const arg of args) {
30
+ if (arg.startsWith("--client-id=")) {
31
+ syncArgs.clientId = arg.split("=")[1];
32
+ }
33
+ else if (arg.startsWith("--client-secret=")) {
34
+ syncArgs.clientSecret = arg.split("=")[1];
35
+ }
36
+ else if (arg.startsWith("--access-token=")) {
37
+ syncArgs.accessToken = arg.split("=")[1];
38
+ }
39
+ else if (arg.startsWith("--refresh-token=")) {
40
+ syncArgs.refreshToken = arg.split("=")[1];
41
+ }
42
+ else if (arg.startsWith("--days=")) {
43
+ syncArgs.days = parseInt(arg.split("=")[1]);
44
+ }
45
+ }
46
+ return syncArgs;
47
+ }
48
+ if (args[0] === "render") {
49
+ if (!args[1]) {
50
+ log.error("render command requires an input file");
51
+ process.exit(1);
52
+ }
53
+ const renderArgs = {
54
+ command: "render",
55
+ inputFile: args[1],
56
+ };
57
+ for (let i = 2; i < args.length; i++) {
58
+ if (args[i] === "--output" || args[i] === "-o") {
59
+ renderArgs.outputFile = args[i + 1];
60
+ i++;
61
+ }
62
+ else if (args[i].startsWith("--output=")) {
63
+ renderArgs.outputFile = args[i].split("=")[1];
64
+ }
65
+ }
66
+ return renderArgs;
67
+ }
68
+ if (args[0] === "query") {
69
+ if (!args[1]) {
70
+ log.error("query command requires a SQL statement");
71
+ process.exit(1);
72
+ }
73
+ const queryArgs = {
74
+ command: "query",
75
+ sql: args[1],
76
+ json: args.includes("--json"),
77
+ };
78
+ return queryArgs;
79
+ }
80
+ if (args[0] === "auth") {
81
+ const authArgs = { command: "auth" };
82
+ for (const arg of args) {
83
+ if (arg.startsWith("--client-id=")) {
84
+ authArgs.clientId = arg.slice("--client-id=".length);
85
+ }
86
+ else if (arg.startsWith("--client-secret=")) {
87
+ authArgs.clientSecret = arg.slice("--client-secret=".length);
88
+ }
89
+ else if (arg.startsWith("--code=")) {
90
+ authArgs.code = arg.slice("--code=".length);
91
+ }
92
+ }
93
+ return authArgs;
94
+ }
95
+ if (args[0] === "validate") {
96
+ if (!args[1]) {
97
+ log.error("validate command requires an input file");
98
+ process.exit(1);
99
+ }
100
+ return {
101
+ command: "validate",
102
+ inputFile: args[1],
103
+ };
104
+ }
105
+ if (args[0] === "schema") {
106
+ return { command: "schema" };
107
+ }
108
+ if (args[0] === "modify") {
109
+ if (!args[1] || !args[2]) {
110
+ log.error("modify command requires --backup and --plan arguments");
111
+ process.exit(1);
112
+ }
113
+ const modifyArgs = {
114
+ command: "modify",
115
+ backup: "",
116
+ plan: "",
117
+ };
118
+ for (let i = 1; i < args.length; i++) {
119
+ if (args[i] === "--backup" || args[i] === "-b") {
120
+ modifyArgs.backup = args[i + 1];
121
+ i++;
122
+ }
123
+ else if (args[i].startsWith("--backup=")) {
124
+ modifyArgs.backup = args[i].split("=")[1];
125
+ }
126
+ else if (args[i] === "--plan" || args[i] === "-p") {
127
+ modifyArgs.plan = args[i + 1];
128
+ i++;
129
+ }
130
+ else if (args[i].startsWith("--plan=")) {
131
+ modifyArgs.plan = args[i].split("=")[1];
132
+ }
133
+ else if (args[i] === "--output" || args[i] === "-o") {
134
+ modifyArgs.output = args[i + 1];
135
+ i++;
136
+ }
137
+ else if (args[i].startsWith("--output=")) {
138
+ modifyArgs.output = args[i].split("=")[1];
139
+ }
140
+ }
141
+ if (!modifyArgs.backup || !modifyArgs.plan) {
142
+ log.error("Both --backup and --plan are required");
143
+ process.exit(1);
144
+ }
145
+ return modifyArgs;
146
+ }
147
+ if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
148
+ return { command: "help" };
149
+ }
150
+ log.error(`Unknown command: ${args[0]}`);
151
+ process.exit(1);
152
+ }
153
+ function printHelp() {
154
+ console.log(`
155
+ Endurance Coach - Training Plan Tools
156
+
157
+ Usage: npx endurance-coach <command> [options]
158
+
159
+ Commands:
160
+ sync Sync activities from Strava
161
+ auth Get Strava authorization URL or exchange code for tokens
162
+ schema Print the training plan JSON schema reference
163
+ validate <file> Validate a training plan JSON against the schema
164
+ render <file> Render a training plan JSON to HTML
165
+ query <sql> Run a SQL query against the database
166
+ modify Apply backup changes to a training plan
167
+ help Show this help message
168
+
169
+ Auth Options (for headless/Claude environments):
170
+ --client-id=ID Strava API client ID
171
+ --client-secret=SEC Strava API client secret
172
+ --code=URL_OR_CODE Full redirect URL or just the authorization code
173
+
174
+ Step 1: Run 'auth' with credentials to get authorization URL
175
+ Step 2: User clicks URL, authorizes, copies entire redirect URL
176
+ Step 3: Run 'auth --code=URL' to exchange for tokens
177
+ Step 4: Run 'sync' to fetch activities
178
+
179
+ Sync Options:
180
+ --client-id=ID Strava API client ID (for OAuth flow)
181
+ --client-secret=SEC Strava API client secret (for OAuth flow)
182
+ --days=N Days of history to sync (default: 730)
183
+
184
+ Render Options:
185
+ --output, -o FILE Output HTML file (default: <input>.html)
186
+
187
+ Query Options:
188
+ --json Output as JSON (default: plain text)
189
+
190
+ Modify Options:
191
+ --backup, -b FILE Backup JSON file (exported from Settings)
192
+ --plan, -p FILE Training plan JSON file to modify
193
+ --output, -o FILE Output file (default: overwrites plan file)
194
+
195
+ Examples:
196
+ # Headless auth flow (for Claude/automated environments)
197
+ npx endurance-coach auth --client-id=12345 --client-secret=abc123
198
+ # User clicks URL, copies code from failed redirect
199
+ npx endurance-coach auth --code=AUTHORIZATION_CODE
200
+ npx endurance-coach sync
201
+
202
+ # Interactive auth flow (opens browser)
203
+ npx endurance-coach sync --client-id=12345 --client-secret=abc123
204
+
205
+ # Get the schema reference for plan JSON
206
+ npx endurance-coach schema
207
+
208
+ # Validate a training plan JSON
209
+ npx endurance-coach validate plan.json
210
+
211
+ # Render a training plan to HTML (includes validation)
212
+ npx endurance-coach render plan.json --output my-plan.html
213
+
214
+ # Query the database
215
+ npx endurance-coach query "SELECT * FROM weekly_volume LIMIT 5"
216
+
217
+ # Apply backup changes to a training plan
218
+ npx endurance-coach modify --backup backup.json --plan plan.json
219
+
220
+ # Save modified plan to a new file
221
+ npx endurance-coach modify -b backup.json -p plan.json -o modified_plan.json
222
+ `);
223
+ }
224
+ // ============================================================================
225
+ // Auth Command (for headless/Claude environments)
226
+ // ============================================================================
227
+ const REDIRECT_PORT = 8765;
228
+ const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
229
+ const AUTHORIZE_URL = "https://www.strava.com/oauth/authorize";
230
+ const TOKEN_URL = "https://www.strava.com/oauth/token";
231
+ async function runAuth(args) {
232
+ // If code is provided, exchange it for tokens
233
+ if (args.code) {
234
+ if (!configExists()) {
235
+ log.error("No configuration found. Run 'auth' with --client-id and --client-secret first.");
236
+ process.exit(1);
237
+ }
238
+ // Extract code from full URL if user pasted the entire redirect URL
239
+ let code = args.code;
240
+ if (code.includes("localhost") || code.startsWith("http")) {
241
+ try {
242
+ const url = new URL(code);
243
+ const extractedCode = url.searchParams.get("code");
244
+ if (extractedCode) {
245
+ code = extractedCode;
246
+ }
247
+ else {
248
+ log.error("Could not find 'code' parameter in URL");
249
+ process.exit(1);
250
+ }
251
+ }
252
+ catch {
253
+ // Not a valid URL, use as-is
254
+ }
255
+ }
256
+ const config = loadConfig();
257
+ log.start("Exchanging authorization code for tokens...");
258
+ const tokenResponse = await fetch(TOKEN_URL, {
259
+ method: "POST",
260
+ headers: { "Content-Type": "application/json" },
261
+ body: JSON.stringify({
262
+ client_id: config.strava.client_id,
263
+ client_secret: config.strava.client_secret,
264
+ code: code,
265
+ grant_type: "authorization_code",
266
+ }),
267
+ });
268
+ if (!tokenResponse.ok) {
269
+ const error = await tokenResponse.text();
270
+ log.error(`Token exchange failed: ${error}`);
271
+ process.exit(1);
272
+ }
273
+ const data = await tokenResponse.json();
274
+ const tokens = {
275
+ access_token: data.access_token,
276
+ refresh_token: data.refresh_token,
277
+ expires_at: data.expires_at,
278
+ athlete_id: data.athlete.id,
279
+ };
280
+ saveTokens(tokens);
281
+ log.success(`Authenticated as ${data.athlete.firstname} ${data.athlete.lastname}`);
282
+ log.ready("Now run: npx endurance-coach sync");
283
+ return;
284
+ }
285
+ // Otherwise, generate and print the authorization URL
286
+ if (!args.clientId || !args.clientSecret) {
287
+ log.error("Required: --client-id and --client-secret");
288
+ log.info("Get these from: https://www.strava.com/settings/api");
289
+ process.exit(1);
290
+ }
291
+ // Save config for later use
292
+ const config = createConfig(args.clientId, args.clientSecret, 730);
293
+ saveConfig(config);
294
+ const authUrl = new URL(AUTHORIZE_URL);
295
+ authUrl.searchParams.set("client_id", args.clientId);
296
+ authUrl.searchParams.set("response_type", "code");
297
+ authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
298
+ authUrl.searchParams.set("scope", "activity:read_all");
299
+ authUrl.searchParams.set("approval_prompt", "auto");
300
+ console.log("\nšŸ“‹ AUTHORIZATION URL:\n");
301
+ console.log(authUrl.toString());
302
+ console.log("\nšŸ“ INSTRUCTIONS:");
303
+ console.log("1. Open the URL above in a browser");
304
+ console.log("2. Click 'Authorize' on Strava");
305
+ console.log("3. You'll be redirected to a page that won't load (that's OK!)");
306
+ console.log("4. Copy the ENTIRE URL from your browser's address bar");
307
+ console.log("5. Paste it back to Claude\n");
308
+ }
309
+ // ============================================================================
310
+ // Sync Command
311
+ // ============================================================================
312
+ function escapeString(str) {
313
+ if (str == null)
314
+ return "NULL";
315
+ return `'${str.replace(/'/g, "''")}'`;
316
+ }
317
+ function insertActivity(activity) {
318
+ const sql = `
319
+ INSERT OR REPLACE INTO activities (
320
+ id, name, sport_type, start_date, elapsed_time, moving_time,
321
+ distance, total_elevation_gain, average_speed, max_speed,
322
+ average_heartrate, max_heartrate, average_watts, max_watts,
323
+ weighted_average_watts, kilojoules, suffer_score, average_cadence,
324
+ calories, description, workout_type, gear_id, raw_json, synced_at
325
+ ) VALUES (
326
+ ${activity.id},
327
+ ${escapeString(activity.name)},
328
+ ${escapeString(activity.sport_type)},
329
+ ${escapeString(activity.start_date)},
330
+ ${activity.elapsed_time ?? "NULL"},
331
+ ${activity.moving_time ?? "NULL"},
332
+ ${activity.distance ?? "NULL"},
333
+ ${activity.total_elevation_gain ?? "NULL"},
334
+ ${activity.average_speed ?? "NULL"},
335
+ ${activity.max_speed ?? "NULL"},
336
+ ${activity.average_heartrate ?? "NULL"},
337
+ ${activity.max_heartrate ?? "NULL"},
338
+ ${activity.average_watts ?? "NULL"},
339
+ ${activity.max_watts ?? "NULL"},
340
+ ${activity.weighted_average_watts ?? "NULL"},
341
+ ${activity.kilojoules ?? "NULL"},
342
+ ${activity.suffer_score ?? "NULL"},
343
+ ${activity.average_cadence ?? "NULL"},
344
+ ${activity.calories ?? "NULL"},
345
+ ${escapeString(activity.description)},
346
+ ${activity.workout_type ?? "NULL"},
347
+ ${escapeString(activity.gear_id)},
348
+ ${escapeString(JSON.stringify(activity))},
349
+ datetime('now')
350
+ );
351
+ `;
352
+ execute(sql);
353
+ }
354
+ function insertAthlete(athlete) {
355
+ const sql = `
356
+ INSERT OR REPLACE INTO athlete (id, firstname, lastname, weight, ftp, raw_json, updated_at)
357
+ VALUES (
358
+ ${athlete.id},
359
+ ${escapeString(athlete.firstname)},
360
+ ${escapeString(athlete.lastname)},
361
+ ${athlete.weight ?? "NULL"},
362
+ ${athlete.ftp ?? "NULL"},
363
+ ${escapeString(JSON.stringify(athlete))},
364
+ datetime('now')
365
+ );
366
+ `;
367
+ execute(sql);
368
+ }
369
+ async function runSync(args) {
370
+ log.box("Endurance Coach - Strava Sync");
371
+ // Step 0: Initialize SQLite backend
372
+ await initDatabase();
373
+ const syncDays = args.days || 730;
374
+ // Step 1: Handle token-based auth (no browser needed)
375
+ if (args.accessToken && args.refreshToken) {
376
+ log.info("Using provided access tokens...");
377
+ // Save tokens - we'll get athlete_id after fetching profile
378
+ // Set expiry to 1 hour from now (we have refresh token for renewal)
379
+ const tempTokens = {
380
+ access_token: args.accessToken,
381
+ refresh_token: args.refreshToken,
382
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
383
+ athlete_id: 0, // Will be updated after fetching athlete
384
+ };
385
+ saveTokens(tempTokens);
386
+ // Create minimal config if needed
387
+ if (!configExists()) {
388
+ // Token-based auth doesn't need client credentials for initial sync
389
+ // but we need them for token refresh - use placeholders
390
+ const config = createConfig("token-auth", "token-auth", syncDays);
391
+ saveConfig(config);
392
+ }
393
+ // Initialize database
394
+ migrate();
395
+ // Fetch athlete to get ID and validate tokens
396
+ log.start("Validating tokens and fetching athlete profile...");
397
+ const athlete = await getAthlete(tempTokens);
398
+ // Update tokens with real athlete ID
399
+ const tokens = { ...tempTokens, athlete_id: athlete.id };
400
+ saveTokens(tokens);
401
+ insertAthlete(athlete);
402
+ log.success(`Authenticated as ${athlete.firstname} ${athlete.lastname}`);
403
+ // Fetch activities
404
+ const afterDate = new Date();
405
+ afterDate.setDate(afterDate.getDate() - syncDays);
406
+ const activities = await getAllActivities(tokens, afterDate);
407
+ // Store activities
408
+ log.start("Storing activities in database...");
409
+ let count = 0;
410
+ for (const activity of activities) {
411
+ insertActivity(activity);
412
+ count++;
413
+ if (count % 50 === 0) {
414
+ log.progress(` Stored ${count}/${activities.length}...`);
415
+ }
416
+ }
417
+ log.progressEnd();
418
+ log.success(`Stored ${activities.length} activities`);
419
+ execute(`
420
+ INSERT INTO sync_log (started_at, completed_at, activities_synced, status)
421
+ VALUES (datetime('now'), datetime('now'), ${activities.length}, 'success');
422
+ `);
423
+ log.info(`Database: ${getDbPath()}`);
424
+ log.ready("Sync complete! You can now create training plans.");
425
+ return;
426
+ }
427
+ // Step 2: OAuth-based auth (requires browser)
428
+ if (!configExists()) {
429
+ if (args.clientId && args.clientSecret) {
430
+ log.info("Creating configuration from command line arguments...");
431
+ const config = createConfig(args.clientId, args.clientSecret, syncDays);
432
+ saveConfig(config);
433
+ log.success("Configuration saved");
434
+ }
435
+ else {
436
+ log.info("No configuration found. Let's set things up.");
437
+ const config = await promptForConfig();
438
+ saveConfig(config);
439
+ log.success("Configuration saved");
440
+ }
441
+ }
442
+ const config = loadConfig();
443
+ const configSyncDays = args.days || config.sync_days || 730;
444
+ // Initialize database
445
+ migrate();
446
+ // Authenticate with Strava (opens browser)
447
+ const tokens = await getValidTokens();
448
+ // Step 4: Fetch and store athlete profile
449
+ log.start("Fetching athlete profile...");
450
+ const athlete = await getAthlete(tokens);
451
+ insertAthlete(athlete);
452
+ log.success(`Athlete: ${athlete.firstname} ${athlete.lastname}`);
453
+ // Step 5: Fetch activities
454
+ const afterDate = new Date();
455
+ afterDate.setDate(afterDate.getDate() - configSyncDays);
456
+ const activities = await getAllActivities(tokens, afterDate);
457
+ // Step 6: Store activities
458
+ log.start("Storing activities in database...");
459
+ let count = 0;
460
+ for (const activity of activities) {
461
+ insertActivity(activity);
462
+ count++;
463
+ if (count % 50 === 0) {
464
+ log.progress(` Stored ${count}/${activities.length}...`);
465
+ }
466
+ }
467
+ log.progressEnd();
468
+ log.success(`Stored ${activities.length} activities`);
469
+ // Step 7: Log sync
470
+ execute(`
471
+ INSERT INTO sync_log (started_at, completed_at, activities_synced, status)
472
+ VALUES (datetime('now'), datetime('now'), ${activities.length}, 'success');
473
+ `);
474
+ log.info(`Database: ${getDbPath()}`);
475
+ log.ready(`Query with: sqlite3 -json "${getDbPath()}" "SELECT * FROM weekly_volume"`);
476
+ }
477
+ // ============================================================================
478
+ // Render Command
479
+ // ============================================================================
480
+ function getTemplatePath() {
481
+ // Look for template in multiple locations
482
+ const locations = [
483
+ join(__dirname, "..", "templates", "plan-viewer.html"),
484
+ join(__dirname, "..", "..", "templates", "plan-viewer.html"),
485
+ join(process.cwd(), "templates", "plan-viewer.html"),
486
+ ];
487
+ for (const loc of locations) {
488
+ try {
489
+ readFileSync(loc);
490
+ return loc;
491
+ }
492
+ catch {
493
+ // Continue to next location
494
+ }
495
+ }
496
+ throw new Error("Could not find plan-viewer.html template");
497
+ }
498
+ function runRender(args) {
499
+ log.start("Rendering training plan...");
500
+ // Read the plan JSON
501
+ let planJson;
502
+ try {
503
+ planJson = readFileSync(args.inputFile, "utf-8");
504
+ }
505
+ catch (err) {
506
+ log.error(`Could not read input file: ${args.inputFile}`);
507
+ process.exit(1);
508
+ }
509
+ // Parse JSON
510
+ let planData;
511
+ try {
512
+ planData = JSON.parse(planJson);
513
+ }
514
+ catch (err) {
515
+ log.error("Input file is not valid JSON");
516
+ process.exit(1);
517
+ }
518
+ // Validate against schema
519
+ const validation = validatePlan(planData);
520
+ if (!validation.success) {
521
+ log.error("Training plan validation failed:");
522
+ console.error(formatValidationErrors(validation.errors));
523
+ process.exit(1);
524
+ }
525
+ log.success("Plan schema validated successfully");
526
+ // Read the template
527
+ const templatePath = getTemplatePath();
528
+ let template = readFileSync(templatePath, "utf-8");
529
+ // Replace the plan data in the template
530
+ const planDataRegex = /<script type="application\/json" id="plan-data">[\s\S]*?<\/script>/;
531
+ const newPlanData = `<script type="application/json" id="plan-data">\n${planJson}\n</script>`;
532
+ template = template.replace(planDataRegex, newPlanData);
533
+ // Output
534
+ if (args.outputFile) {
535
+ writeFileSync(args.outputFile, template);
536
+ log.success(`Training plan rendered to: ${args.outputFile}`);
537
+ }
538
+ else {
539
+ // Output to stdout
540
+ console.log(template);
541
+ }
542
+ }
543
+ // ============================================================================
544
+ // Query Command
545
+ // ============================================================================
546
+ async function runQuery(args) {
547
+ await initDatabase();
548
+ if (args.json) {
549
+ const results = queryJson(args.sql);
550
+ console.log(JSON.stringify(results, null, 2));
551
+ }
552
+ else {
553
+ const result = query(args.sql);
554
+ console.log(result);
555
+ }
556
+ }
557
+ // ============================================================================
558
+ // Validate Command
559
+ // ============================================================================
560
+ function runValidate(args) {
561
+ log.start("Validating training plan...");
562
+ // Read the plan JSON
563
+ let planJson;
564
+ try {
565
+ planJson = readFileSync(args.inputFile, "utf-8");
566
+ }
567
+ catch (err) {
568
+ log.error(`Could not read input file: ${args.inputFile}`);
569
+ process.exit(1);
570
+ }
571
+ // Parse JSON
572
+ let planData;
573
+ try {
574
+ planData = JSON.parse(planJson);
575
+ }
576
+ catch (err) {
577
+ log.error("Input file is not valid JSON");
578
+ process.exit(1);
579
+ }
580
+ // Validate against schema
581
+ const validation = validatePlan(planData);
582
+ if (!validation.success) {
583
+ log.error("Validation failed:");
584
+ console.error(formatValidationErrors(validation.errors));
585
+ process.exit(1);
586
+ }
587
+ log.success("Plan is valid!");
588
+ }
589
+ // ============================================================================
590
+ // Schema Command
591
+ // ============================================================================
592
+ function runSchema() {
593
+ console.log(`
594
+ # Training Plan JSON Schema Reference
595
+
596
+ This document describes the required structure for training plan JSON files.
597
+
598
+ ## Root Structure
599
+
600
+ \`\`\`typescript
601
+ {
602
+ version: "1.0", // Required: Must be exactly "1.0"
603
+ meta: PlanMeta, // Required: Plan metadata
604
+ preferences: UnitPreferences, // Required: Unit system preferences
605
+ assessment: AthleteAssessment, // Required: Athlete fitness assessment
606
+ zones: AthleteZones, // Required: Training zones
607
+ phases: TrainingPhase[], // Required: Macro training phases
608
+ weeks: TrainingWeek[], // Required: Weekly training schedule
609
+ raceStrategy: RaceStrategy // Required: Race day strategy
610
+ }
611
+ \`\`\`
612
+
613
+ ## Enums (Valid Values)
614
+
615
+ ### Sport
616
+ \`"swim" | "bike" | "run" | "strength" | "brick" | "race" | "rest"\`
617
+
618
+ ### WorkoutType
619
+ \`"rest" | "recovery" | "endurance" | "tempo" | "threshold" | "intervals" | "vo2max" | "sprint" | "race" | "brick" | "technique" | "openwater" | "hills" | "long"\`
620
+
621
+ ### FoundationLevel
622
+ \`"beginner" | "intermediate" | "advanced" | "elite"\`
623
+
624
+ ### Unit Preferences
625
+ - swim: \`"meters" | "yards"\`
626
+ - bike: \`"kilometers" | "miles"\`
627
+ - run: \`"kilometers" | "miles"\`
628
+ - firstDayOfWeek: \`"monday" | "sunday"\`
629
+
630
+ ## Key Objects
631
+
632
+ ### PlanMeta
633
+ \`\`\`typescript
634
+ {
635
+ id: string, // Unique plan identifier
636
+ athlete: string, // Athlete's name
637
+ event: string, // Target event name
638
+ eventDate: "YYYY-MM-DD", // Event date (ISO format)
639
+ planStartDate: "YYYY-MM-DD", // Plan start date
640
+ planEndDate: "YYYY-MM-DD", // Plan end date
641
+ createdAt: string, // ISO datetime
642
+ updatedAt: string, // ISO datetime
643
+ totalWeeks: number, // Total weeks in plan
644
+ generatedBy: string // "Endurance Coach"
645
+ }
646
+ \`\`\`
647
+
648
+ ### Workout
649
+ \`\`\`typescript
650
+ {
651
+ id: string, // Required: Unique workout ID
652
+ sport: Sport, // Required: See Sport enum
653
+ type: WorkoutType, // Required: See WorkoutType enum
654
+ name: string, // Required: Workout name
655
+ description: string, // Required: Workout description
656
+ durationMinutes?: number, // Optional: Duration in minutes
657
+ distanceMeters?: number, // Optional: Distance in meters
658
+ primaryZone?: string, // Optional: Target zone ("Zone 2", etc.)
659
+ targetHR?: { low: number, high: number },
660
+ targetPower?: { low: number, high: number },
661
+ targetPace?: { low: string, high: string },
662
+ rpe?: number, // Optional: 1-10 RPE scale
663
+ structure?: StructuredWorkout, // Optional: For device export
664
+ humanReadable?: string, // Optional: Workout text
665
+ completed: boolean // Required: Always false for new plans
666
+ }
667
+ \`\`\`
668
+
669
+ ### TrainingDay
670
+ \`\`\`typescript
671
+ {
672
+ date: "YYYY-MM-DD", // Required: ISO date format
673
+ dayOfWeek: string, // Required: "Monday", "Tuesday", etc.
674
+ workouts: Workout[] // Required: Array of workouts
675
+ }
676
+ \`\`\`
677
+
678
+ ### TrainingWeek
679
+ \`\`\`typescript
680
+ {
681
+ weekNumber: number, // Required: 1-based week number
682
+ startDate: "YYYY-MM-DD", // Required: Week start date
683
+ endDate: "YYYY-MM-DD", // Required: Week end date
684
+ phase: string, // Required: Phase name
685
+ focus: string, // Required: Week focus
686
+ targetHours: number, // Required: Target hours
687
+ days: TrainingDay[], // Required: 7 days
688
+ summary: WeekSummary, // Required: Week totals
689
+ isRecoveryWeek: boolean // Required: Recovery week flag
690
+ }
691
+ \`\`\`
692
+
693
+ ### WeekSummary
694
+ \`\`\`typescript
695
+ {
696
+ totalHours: number, // Required: Total hours
697
+ totalTSS?: number, // Optional: Training stress score
698
+ bySport?: { // Optional: Breakdown by sport
699
+ [sport]: { sessions: number, hours: number, km?: number }
700
+ }
701
+ }
702
+ \`\`\`
703
+
704
+ ### AthleteAssessment
705
+ \`\`\`typescript
706
+ {
707
+ foundation: {
708
+ raceHistory: string[], // Past race names
709
+ peakTrainingLoad: number, // Peak hours/week
710
+ foundationLevel: FoundationLevel,
711
+ yearsInSport: number
712
+ },
713
+ currentForm: {
714
+ weeklyVolume: { total: number, swim?: number, bike?: number, run?: number },
715
+ longestSessions: { swim?: number, bike?: number, run?: number },
716
+ consistency: number // Sessions/week
717
+ },
718
+ strengths: [{ sport: Sport, evidence: string }],
719
+ limiters: [{ sport: Sport, evidence: string }],
720
+ constraints: string[] // Schedule/injury constraints
721
+ }
722
+ \`\`\`
723
+
724
+ ### TrainingPhase
725
+ \`\`\`typescript
726
+ {
727
+ name: string, // "Base", "Build", "Peak", "Taper"
728
+ startWeek: number, // Starting week number
729
+ endWeek: number, // Ending week number
730
+ focus: string, // Phase focus
731
+ weeklyHoursRange: { low: number, high: number },
732
+ keyWorkouts: string[], // Key session types
733
+ physiologicalGoals: string[] // Training adaptations
734
+ }
735
+ \`\`\`
736
+
737
+ ### AthleteZones
738
+ \`\`\`typescript
739
+ {
740
+ run?: {
741
+ hr?: { lthr: number, zones: HRZone[] },
742
+ pace?: { thresholdPace: string, thresholdPaceSeconds: number, zones: PaceZone[] }
743
+ },
744
+ bike?: {
745
+ hr?: { lthr: number, zones: HRZone[] },
746
+ power?: { ftp: number, zones: PowerZone[] }
747
+ },
748
+ swim?: {
749
+ css: string, // "1:45/100m"
750
+ cssSeconds: number, // Per 100m
751
+ zones: SwimZone[]
752
+ },
753
+ maxHR?: number,
754
+ restingHR?: number,
755
+ weight?: number // kg
756
+ }
757
+ \`\`\`
758
+
759
+ ### RaceStrategy
760
+ \`\`\`typescript
761
+ {
762
+ event: {
763
+ name: string,
764
+ date: string,
765
+ type: string,
766
+ distances?: { swim?: number, bike?: number, run?: number }
767
+ },
768
+ pacing: {
769
+ swim?: { target: string, notes: string },
770
+ bike?: { targetPower: string, targetHR: string, notes: string },
771
+ run?: { targetPace: string, targetHR: string, notes: string }
772
+ },
773
+ nutrition: {
774
+ preRace: string,
775
+ during: { carbsPerHour: number, fluidPerHour: string, products: string[] },
776
+ notes: string
777
+ },
778
+ taper: {
779
+ startDate: string,
780
+ volumeReduction: number, // Percentage
781
+ notes: string
782
+ },
783
+ raceDay?: {
784
+ wakeUpTime?: string,
785
+ preRaceMeal?: string,
786
+ warmUp?: string,
787
+ mentalCues?: string[]
788
+ }
789
+ }
790
+ \`\`\`
791
+
792
+ ## Common Validation Errors
793
+
794
+ 1. **Date format**: All dates must be "YYYY-MM-DD" (e.g., "2025-11-03")
795
+ 2. **Missing completed**: Every workout must have \`completed: false\`
796
+ 3. **Invalid sport**: Must use exact enum values, case-sensitive
797
+ 4. **Missing required fields**: Check all required fields are present
798
+ 5. **Invalid version**: Must be exactly "1.0"
799
+
800
+ ## Validation
801
+
802
+ Use these commands to validate your plan:
803
+
804
+ \`\`\`bash
805
+ # Validate only
806
+ npx endurance-coach validate plan.json
807
+
808
+ # Render (includes validation)
809
+ npx endurance-coach render plan.json --output plan.html
810
+ \`\`\`
811
+ `);
812
+ }
813
+ /**
814
+ * Extract plan ID, changes, and completed status from backup localStorage data
815
+ */
816
+ function extractDataFromBackup(backupData) {
817
+ // Find the changes key (format: "plan-{id}-changes")
818
+ const changesKey = Object.keys(backupData).find((key) => key.endsWith("-changes"));
819
+ if (!changesKey) {
820
+ return { planId: null, changes: null, completed: null };
821
+ }
822
+ // Extract plan ID from key
823
+ const planId = changesKey.replace(/^plan-/, "").replace(/-changes$/, "");
824
+ // Parse the changes JSON
825
+ let changes = null;
826
+ try {
827
+ const changesJson = backupData[changesKey];
828
+ changes = JSON.parse(changesJson);
829
+ }
830
+ catch (error) {
831
+ console.error("Failed to parse changes:", error);
832
+ }
833
+ // Find and parse completed workouts
834
+ const completedKey = `plan-${planId}-completed`;
835
+ let completed = null;
836
+ if (backupData[completedKey]) {
837
+ try {
838
+ completed = JSON.parse(backupData[completedKey]);
839
+ }
840
+ catch (error) {
841
+ console.error("Failed to parse completed data:", error);
842
+ }
843
+ }
844
+ return { planId, changes, completed };
845
+ }
846
+ /**
847
+ * Apply completed status to workouts in the plan
848
+ */
849
+ function applyCompletedStatus(plan, completed) {
850
+ const completedCount = Object.keys(completed).filter((id) => completed[id]).length;
851
+ console.log(`Applying completed status to ${completedCount} workouts...`);
852
+ let appliedCount = 0;
853
+ plan.weeks?.forEach((week) => {
854
+ week.days?.forEach((day) => {
855
+ day.workouts?.forEach((workout) => {
856
+ if (completed[workout.id] !== undefined) {
857
+ workout.completed = completed[workout.id];
858
+ if (completed[workout.id]) {
859
+ appliedCount++;
860
+ console.log(` - Marked ${workout.id} as completed`);
861
+ }
862
+ }
863
+ });
864
+ });
865
+ });
866
+ if (appliedCount > 0) {
867
+ console.log(`Applied completed status to ${appliedCount} workouts`);
868
+ }
869
+ }
870
+ /**
871
+ * Apply changes to the training plan
872
+ */
873
+ function applyChangesToPlan(plan, changes) {
874
+ const modifiedPlan = JSON.parse(JSON.stringify(plan));
875
+ // Track all workouts by ID for easy lookup
876
+ const workoutMap = new Map();
877
+ modifiedPlan.weeks?.forEach((week, weekIdx) => {
878
+ week.days?.forEach((day, dayIdx) => {
879
+ day.workouts?.forEach((workout, workoutIdx) => {
880
+ workoutMap.set(workout.id, { weekIdx, dayIdx, workoutIdx });
881
+ });
882
+ });
883
+ });
884
+ // 1. Apply deleted workouts
885
+ console.log(`Applying ${changes.deleted.length} deletions...`);
886
+ changes.deleted.forEach((workoutId) => {
887
+ const location = workoutMap.get(workoutId);
888
+ if (location) {
889
+ const { weekIdx, dayIdx, workoutIdx } = location;
890
+ modifiedPlan.weeks[weekIdx].days[dayIdx].workouts.splice(workoutIdx, 1);
891
+ console.log(` - Deleted workout: ${workoutId}`);
892
+ }
893
+ });
894
+ // Rebuild workout map after deletions
895
+ workoutMap.clear();
896
+ modifiedPlan.weeks?.forEach((week, weekIdx) => {
897
+ week.days?.forEach((day, dayIdx) => {
898
+ day.workouts?.forEach((workout, workoutIdx) => {
899
+ workoutMap.set(workout.id, { weekIdx, dayIdx, workoutIdx });
900
+ });
901
+ });
902
+ });
903
+ // 2. Apply edits to existing workouts
904
+ const editCount = Object.keys(changes.edited).length;
905
+ console.log(`Applying ${editCount} edits...`);
906
+ Object.entries(changes.edited).forEach(([workoutId, edits]) => {
907
+ const location = workoutMap.get(workoutId);
908
+ if (location) {
909
+ const { weekIdx, dayIdx, workoutIdx } = location;
910
+ const workout = modifiedPlan.weeks[weekIdx].days[dayIdx].workouts[workoutIdx];
911
+ Object.assign(workout, edits);
912
+ console.log(` - Edited workout: ${workoutId}`);
913
+ }
914
+ });
915
+ // 3. Apply moved workouts
916
+ const moveCount = Object.keys(changes.moved).length;
917
+ console.log(`Applying ${moveCount} moves...`);
918
+ Object.entries(changes.moved).forEach(([workoutId, newDate]) => {
919
+ const location = workoutMap.get(workoutId);
920
+ if (!location)
921
+ return;
922
+ const { weekIdx, dayIdx, workoutIdx } = location;
923
+ // Remove workout from original location
924
+ const [workout] = modifiedPlan.weeks[weekIdx].days[dayIdx].workouts.splice(workoutIdx, 1);
925
+ // Find the target day
926
+ let targetDay = null;
927
+ let targetWeekIdx = -1;
928
+ let targetDayIdx = -1;
929
+ for (let wIdx = 0; wIdx < modifiedPlan.weeks.length; wIdx++) {
930
+ const week = modifiedPlan.weeks[wIdx];
931
+ for (let dIdx = 0; dIdx < week.days.length; dIdx++) {
932
+ const day = week.days[dIdx];
933
+ if (day.date === newDate) {
934
+ targetDay = day;
935
+ targetWeekIdx = wIdx;
936
+ targetDayIdx = dIdx;
937
+ break;
938
+ }
939
+ }
940
+ if (targetDay)
941
+ break;
942
+ }
943
+ if (targetDay) {
944
+ // Add workout to new location
945
+ if (!targetDay.workouts) {
946
+ targetDay.workouts = [];
947
+ }
948
+ targetDay.workouts.push(workout);
949
+ console.log(` - Moved workout ${workoutId} to ${newDate}`);
950
+ }
951
+ else {
952
+ console.warn(` ! Could not find target date ${newDate} for workout ${workoutId}`);
953
+ }
954
+ });
955
+ // 4. Add new workouts
956
+ const addCount = Object.keys(changes.added).length;
957
+ console.log(`Adding ${addCount} new workouts...`);
958
+ Object.entries(changes.added).forEach(([workoutId, { date, workout }]) => {
959
+ // Find the target day
960
+ let targetDay = null;
961
+ for (const week of modifiedPlan.weeks || []) {
962
+ for (const day of week.days || []) {
963
+ if (day.date === date) {
964
+ targetDay = day;
965
+ break;
966
+ }
967
+ }
968
+ if (targetDay)
969
+ break;
970
+ }
971
+ if (targetDay) {
972
+ if (!targetDay.workouts) {
973
+ targetDay.workouts = [];
974
+ }
975
+ targetDay.workouts.push(workout);
976
+ console.log(` - Added workout ${workoutId} on ${date}`);
977
+ }
978
+ else {
979
+ console.warn(` ! Could not find date ${date} for new workout ${workoutId}`);
980
+ }
981
+ });
982
+ // Update the plan's updatedAt timestamp
983
+ modifiedPlan.meta.updatedAt = new Date().toISOString();
984
+ return modifiedPlan;
985
+ }
986
+ export function modifyCommand(options) {
987
+ console.log("šŸ“ Modifying training plan...\n");
988
+ try {
989
+ // 1. Read backup file
990
+ console.log(`Reading backup: ${options.backup}`);
991
+ const backupContent = readFileSync(options.backup, "utf-8");
992
+ const backupData = JSON.parse(backupContent);
993
+ // 2. Extract changes and completed status from backup
994
+ const { planId, changes, completed } = extractDataFromBackup(backupData);
995
+ if (!changes) {
996
+ console.error("āŒ No changes found in backup file");
997
+ process.exit(1);
998
+ }
999
+ console.log(`Found data for plan: ${planId}`);
1000
+ if (completed) {
1001
+ const completedCount = Object.keys(completed).filter((id) => completed[id]).length;
1002
+ console.log(`Found ${completedCount} completed workouts in backup`);
1003
+ }
1004
+ console.log();
1005
+ // 3. Read plan file
1006
+ console.log(`Reading plan: ${options.plan}`);
1007
+ const planContent = readFileSync(options.plan, "utf-8");
1008
+ const plan = JSON.parse(planContent);
1009
+ // Verify plan IDs match
1010
+ if (plan.meta.id !== planId) {
1011
+ console.warn(`āš ļø Warning: Plan ID mismatch!\n Backup: ${planId}\n Plan: ${plan.meta.id}`);
1012
+ console.log(" Continuing anyway...\n");
1013
+ }
1014
+ // 4. Apply changes
1015
+ console.log("Applying changes:\n");
1016
+ const modifiedPlan = applyChangesToPlan(plan, changes);
1017
+ // 5. Apply completed status if available
1018
+ if (completed) {
1019
+ console.log();
1020
+ applyCompletedStatus(modifiedPlan, completed);
1021
+ }
1022
+ // 6. Write output
1023
+ const outputPath = options.output || options.plan;
1024
+ console.log(`\nWriting modified plan to: ${outputPath}`);
1025
+ writeFileSync(outputPath, JSON.stringify(modifiedPlan, null, 2));
1026
+ console.log("\nāœ… Plan modified successfully!");
1027
+ console.log(`\nSummary:`);
1028
+ console.log(` - Deleted: ${changes.deleted.length} workouts`);
1029
+ console.log(` - Edited: ${Object.keys(changes.edited).length} workouts`);
1030
+ console.log(` - Moved: ${Object.keys(changes.moved).length} workouts`);
1031
+ console.log(` - Added: ${Object.keys(changes.added).length} workouts`);
1032
+ if (completed) {
1033
+ const completedCount = Object.keys(completed).filter((id) => completed[id]).length;
1034
+ console.log(` - Completed: ${completedCount} workouts marked as done`);
1035
+ }
1036
+ }
1037
+ catch (error) {
1038
+ console.error("āŒ Error modifying plan:", error instanceof Error ? error.message : error);
1039
+ process.exit(1);
1040
+ }
1041
+ }
1042
+ // ============================================================================
1043
+ // Main
1044
+ // ============================================================================
1045
+ async function main() {
1046
+ const args = parseArgs();
1047
+ switch (args.command) {
1048
+ case "help":
1049
+ printHelp();
1050
+ break;
1051
+ case "auth":
1052
+ await runAuth(args);
1053
+ break;
1054
+ case "sync":
1055
+ await runSync(args);
1056
+ break;
1057
+ case "schema":
1058
+ runSchema();
1059
+ break;
1060
+ case "validate":
1061
+ runValidate(args);
1062
+ break;
1063
+ case "render":
1064
+ runRender(args);
1065
+ break;
1066
+ case "query":
1067
+ await runQuery(args);
1068
+ break;
1069
+ case "modify":
1070
+ modifyCommand(args);
1071
+ break;
1072
+ }
1073
+ }
1074
+ main().catch((err) => {
1075
+ log.error(err.message);
1076
+ process.exit(1);
1077
+ });