endurance-coach 0.1.1 → 1.0.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 (74) hide show
  1. package/README.md +3 -0
  2. package/dist/cli.js +318 -35
  3. package/dist/expander/expander.d.ts +20 -0
  4. package/dist/expander/expander.js +339 -0
  5. package/dist/expander/index.d.ts +8 -0
  6. package/dist/expander/index.js +9 -0
  7. package/dist/expander/types.d.ts +169 -0
  8. package/dist/expander/types.js +6 -0
  9. package/dist/expander/zones.d.ts +50 -0
  10. package/dist/expander/zones.js +159 -0
  11. package/dist/index.d.ts +4 -0
  12. package/dist/index.js +9 -1
  13. package/dist/schema/compact-plan.d.ts +175 -0
  14. package/dist/schema/compact-plan.js +64 -0
  15. package/dist/schema/compact-plan.schema.d.ts +277 -0
  16. package/dist/schema/compact-plan.schema.js +205 -0
  17. package/dist/templates/index.d.ts +10 -0
  18. package/dist/templates/index.js +13 -0
  19. package/dist/templates/interpolate.d.ts +51 -0
  20. package/dist/templates/interpolate.js +204 -0
  21. package/dist/templates/loader.d.ts +19 -0
  22. package/dist/templates/loader.js +129 -0
  23. package/dist/templates/template.schema.d.ts +401 -0
  24. package/dist/templates/template.schema.js +101 -0
  25. package/dist/templates/template.types.d.ts +155 -0
  26. package/dist/templates/template.types.js +7 -0
  27. package/dist/templates/yaml-parser.d.ts +15 -0
  28. package/dist/templates/yaml-parser.js +18 -0
  29. package/package.json +2 -1
  30. package/templates/bike/CLAUDE.md +7 -0
  31. package/templates/bike/easy.yaml +38 -0
  32. package/templates/bike/endurance.yaml +42 -0
  33. package/templates/bike/hills.yaml +80 -0
  34. package/templates/bike/overunders.yaml +81 -0
  35. package/templates/bike/rest.yaml +16 -0
  36. package/templates/bike/sweetspot.yaml +80 -0
  37. package/templates/bike/tempo.yaml +79 -0
  38. package/templates/bike/threshold.yaml +83 -0
  39. package/templates/bike/vo2max.yaml +84 -0
  40. package/templates/brick/CLAUDE.md +7 -0
  41. package/templates/brick/halfironman.yaml +72 -0
  42. package/templates/brick/ironman.yaml +72 -0
  43. package/templates/brick/olympic.yaml +70 -0
  44. package/templates/brick/sprint.yaml +70 -0
  45. package/templates/plan-viewer.html +22 -22
  46. package/templates/run/CLAUDE.md +7 -0
  47. package/templates/run/easy.yaml +36 -0
  48. package/templates/run/fartlek.yaml +40 -0
  49. package/templates/run/hills.yaml +36 -0
  50. package/templates/run/intervals.1k.yaml +63 -0
  51. package/templates/run/intervals.400.yaml +63 -0
  52. package/templates/run/intervals.800.yaml +63 -0
  53. package/templates/run/intervals.mile.yaml +64 -0
  54. package/templates/run/long.yaml +41 -0
  55. package/templates/run/progression.yaml +49 -0
  56. package/templates/run/race.5k.yaml +36 -0
  57. package/templates/run/recovery.yaml +36 -0
  58. package/templates/run/rest.yaml +16 -0
  59. package/templates/run/strides.yaml +49 -0
  60. package/templates/run/tempo.yaml +56 -0
  61. package/templates/run/threshold.yaml +56 -0
  62. package/templates/strength/CLAUDE.md +7 -0
  63. package/templates/strength/core.yaml +56 -0
  64. package/templates/strength/foundation.yaml +65 -0
  65. package/templates/strength/full.yaml +73 -0
  66. package/templates/strength/maintenance.yaml +62 -0
  67. package/templates/swim/CLAUDE.md +7 -0
  68. package/templates/swim/aerobic.yaml +67 -0
  69. package/templates/swim/easy.yaml +51 -0
  70. package/templates/swim/openwater.yaml +60 -0
  71. package/templates/swim/rest.yaml +16 -0
  72. package/templates/swim/technique.yaml +67 -0
  73. package/templates/swim/threshold.yaml +75 -0
  74. package/templates/swim/vo2max.yaml +88 -0
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Endurance Coach
2
2
 
3
+ Originally forked from Claude Coach.
4
+ This project is now independently maintained and has evolved with a different architecture and goals, focusing on agent-first, schema-driven endurance planning.
5
+
3
6
  Endurance Coach allows you to use Claude (or any AI assistant) to create custom-tailored training programs for triathlons, marathons, and other endurance activities. Using a data-driven approach and principles from top training plans, the AI will create a training plan that's uniquely fit for you, your personal fitness, and the constraints you have in the next couple of weeks. Maybe you're recovering from an injury, maybe you're traveling and don't have access to a pool or track in a certain week - tell the AI about it and it'll create a plan that works for you.
4
7
 
5
8
  The output is a beautiful training plan app that allows you to add, edit, or move workouts, mark them as complete, and update key training data like heart rate zones, LTHR, threshold paces, FTP, and others. Your data is kept locally in your browser.
package/dist/cli.js CHANGED
@@ -9,6 +9,9 @@ import { dirname, join } from "path";
9
9
  import { fileURLToPath } from "url";
10
10
  import { ProxyAgent, setGlobalDispatcher } from "undici";
11
11
  import { validatePlan, formatValidationErrors } from "./schema/training-plan.schema.js";
12
+ import { validateCompactPlan, formatCompactValidationErrors, } from "./schema/compact-plan.schema.js";
13
+ import { loadTemplates, parseYaml, stringifyYaml } from "./templates/index.js";
14
+ import { expandPlan, validateWorkoutRefs } from "./expander/index.js";
12
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
16
  // ============================================================================
14
17
  // Proxy Configuration
@@ -62,6 +65,10 @@ function parseArgs() {
62
65
  else if (args[i].startsWith("--output=")) {
63
66
  renderArgs.outputFile = args[i].split("=")[1];
64
67
  }
68
+ else if (!args[i].startsWith("-") && !renderArgs.outputFile) {
69
+ // Accept positional output argument (helps when npm consumes -o)
70
+ renderArgs.outputFile = args[i];
71
+ }
65
72
  }
66
73
  return renderArgs;
67
74
  }
@@ -97,10 +104,61 @@ function parseArgs() {
97
104
  log.error("validate command requires an input file");
98
105
  process.exit(1);
99
106
  }
100
- return {
107
+ const validateArgs = {
101
108
  command: "validate",
102
109
  inputFile: args[1],
110
+ compact: args.includes("--compact") || args[1].endsWith(".yaml") || args[1].endsWith(".yml"),
111
+ };
112
+ return validateArgs;
113
+ }
114
+ if (args[0] === "expand") {
115
+ if (!args[1]) {
116
+ log.error("expand command requires an input file");
117
+ process.exit(1);
118
+ }
119
+ const expandArgs = {
120
+ command: "expand",
121
+ inputFile: args[1],
122
+ };
123
+ for (let i = 2; i < args.length; i++) {
124
+ if (args[i] === "--output" || args[i] === "-o") {
125
+ expandArgs.outputFile = args[i + 1];
126
+ i++;
127
+ }
128
+ else if (args[i].startsWith("--output=")) {
129
+ expandArgs.outputFile = args[i].split("=")[1];
130
+ }
131
+ else if (args[i] === "--format") {
132
+ expandArgs.format = args[i + 1];
133
+ i++;
134
+ }
135
+ else if (args[i].startsWith("--format=")) {
136
+ expandArgs.format = args[i].split("=")[1];
137
+ }
138
+ else if (args[i] === "--verbose" || args[i] === "-v") {
139
+ expandArgs.verbose = true;
140
+ }
141
+ }
142
+ return expandArgs;
143
+ }
144
+ if (args[0] === "templates") {
145
+ const templatesArgs = {
146
+ command: "templates",
103
147
  };
148
+ for (let i = 1; i < args.length; i++) {
149
+ if (args[i] === "--sport") {
150
+ templatesArgs.sport = args[i + 1];
151
+ i++;
152
+ }
153
+ else if (args[i].startsWith("--sport=")) {
154
+ templatesArgs.sport = args[i].split("=")[1];
155
+ }
156
+ else if (args[i] === "show") {
157
+ templatesArgs.show = args[i + 1];
158
+ i++;
159
+ }
160
+ }
161
+ return templatesArgs;
104
162
  }
105
163
  if (args[0] === "schema") {
106
164
  return { command: "schema" };
@@ -160,8 +218,10 @@ Commands:
160
218
  sync Sync activities from Strava
161
219
  auth Get Strava authorization URL or exchange code for tokens
162
220
  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
221
+ validate <file> Validate a training plan against the schema
222
+ expand <file> Expand a compact YAML plan to full JSON format
223
+ render <file> Render a training plan to HTML
224
+ templates List available workout templates
165
225
  query <sql> Run a SQL query against the database
166
226
  modify Apply backup changes to a training plan
167
227
  help Show this help message
@@ -181,6 +241,18 @@ Sync Options:
181
241
  --client-secret=SEC Strava API client secret (for OAuth flow)
182
242
  --days=N Days of history to sync (default: 730)
183
243
 
244
+ Validate Options:
245
+ --compact Force compact plan validation (auto-detected for .yaml files)
246
+
247
+ Expand Options:
248
+ --output, -o FILE Output file (default: stdout)
249
+ --format json|yaml Output format (default: json)
250
+ --verbose, -v Show template resolution details
251
+
252
+ Templates Options:
253
+ --sport SPORT Filter by sport (run, bike, swim)
254
+ show <template-id> Show details of a specific template
255
+
184
256
  Render Options:
185
257
  --output, -o FILE Output HTML file (default: <input>.html)
186
258
 
@@ -497,32 +569,62 @@ function getTemplatePath() {
497
569
  }
498
570
  function runRender(args) {
499
571
  log.start("Rendering training plan...");
500
- // Read the plan JSON
501
- let planJson;
572
+ const isCompact = args.inputFile.endsWith(".yaml") || args.inputFile.endsWith(".yml");
573
+ // Read the plan file
574
+ let planContent;
502
575
  try {
503
- planJson = readFileSync(args.inputFile, "utf-8");
576
+ planContent = readFileSync(args.inputFile, "utf-8");
504
577
  }
505
- catch (err) {
578
+ catch {
506
579
  log.error(`Could not read input file: ${args.inputFile}`);
507
580
  process.exit(1);
508
581
  }
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);
582
+ let planJson;
583
+ if (isCompact) {
584
+ // Handle compact YAML plan
585
+ log.info("Detected compact YAML plan, expanding...");
586
+ // Parse YAML
587
+ let compactData;
588
+ try {
589
+ compactData = parseYaml(planContent);
590
+ }
591
+ catch {
592
+ log.error("Input file is not valid YAML");
593
+ process.exit(1);
594
+ }
595
+ // Validate compact plan
596
+ const compactValidation = validateCompactPlan(compactData);
597
+ if (!compactValidation.success) {
598
+ log.error("Compact plan validation failed:");
599
+ console.error(formatCompactValidationErrors(compactValidation.errors));
600
+ process.exit(1);
601
+ }
602
+ // Load templates and expand
603
+ const templates = loadTemplates();
604
+ const expanded = expandPlan(compactValidation.data, templates);
605
+ log.success("Plan expanded successfully");
606
+ planJson = JSON.stringify(expanded, null, 2);
517
607
  }
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);
608
+ else {
609
+ // Handle full JSON plan
610
+ let planData;
611
+ try {
612
+ planData = JSON.parse(planContent);
613
+ }
614
+ catch {
615
+ log.error("Input file is not valid JSON");
616
+ process.exit(1);
617
+ }
618
+ // Validate against schema
619
+ const validation = validatePlan(planData);
620
+ if (!validation.success) {
621
+ log.error("Training plan validation failed:");
622
+ console.error(formatValidationErrors(validation.errors));
623
+ process.exit(1);
624
+ }
625
+ log.success("Plan schema validated successfully");
626
+ planJson = planContent;
524
627
  }
525
- log.success("Plan schema validated successfully");
526
628
  // Read the template
527
629
  const templatePath = getTemplatePath();
528
630
  let template = readFileSync(templatePath, "utf-8");
@@ -558,33 +660,208 @@ async function runQuery(args) {
558
660
  // Validate Command
559
661
  // ============================================================================
560
662
  function runValidate(args) {
561
- log.start("Validating training plan...");
562
- // Read the plan JSON
563
- let planJson;
663
+ const isCompact = args.compact;
664
+ log.start(`Validating ${isCompact ? "compact" : "full"} training plan...`);
665
+ // Read the plan file
666
+ let planContent;
564
667
  try {
565
- planJson = readFileSync(args.inputFile, "utf-8");
668
+ planContent = readFileSync(args.inputFile, "utf-8");
566
669
  }
567
- catch (err) {
670
+ catch {
568
671
  log.error(`Could not read input file: ${args.inputFile}`);
569
672
  process.exit(1);
570
673
  }
571
- // Parse JSON
674
+ // Parse content (YAML or JSON)
572
675
  let planData;
573
676
  try {
574
- planData = JSON.parse(planJson);
677
+ if (args.inputFile.endsWith(".yaml") || args.inputFile.endsWith(".yml")) {
678
+ planData = parseYaml(planContent);
679
+ }
680
+ else {
681
+ planData = JSON.parse(planContent);
682
+ }
575
683
  }
576
684
  catch (err) {
577
- log.error("Input file is not valid JSON");
685
+ log.error(`Input file is not valid ${isCompact ? "YAML" : "JSON"}`);
578
686
  process.exit(1);
579
687
  }
580
- // Validate against schema
581
- const validation = validatePlan(planData);
688
+ if (isCompact) {
689
+ // Validate against compact schema
690
+ const validation = validateCompactPlan(planData);
691
+ if (!validation.success) {
692
+ log.error("Compact plan validation failed:");
693
+ console.error(formatCompactValidationErrors(validation.errors));
694
+ process.exit(1);
695
+ }
696
+ // Also validate template references
697
+ const templates = loadTemplates();
698
+ const templateErrors = validateWorkoutRefs(validation.data, templates);
699
+ if (templateErrors.length > 0) {
700
+ log.warn("Template reference warnings:");
701
+ templateErrors.forEach((e) => console.error(` - ${e}`));
702
+ }
703
+ log.success("Compact plan is valid!");
704
+ }
705
+ else {
706
+ // Validate against full schema
707
+ const validation = validatePlan(planData);
708
+ if (!validation.success) {
709
+ log.error("Validation failed:");
710
+ console.error(formatValidationErrors(validation.errors));
711
+ process.exit(1);
712
+ }
713
+ log.success("Plan is valid!");
714
+ }
715
+ }
716
+ // ============================================================================
717
+ // Expand Command
718
+ // ============================================================================
719
+ function runExpand(args) {
720
+ log.start("Expanding compact plan...");
721
+ // Read the compact plan
722
+ let planContent;
723
+ try {
724
+ planContent = readFileSync(args.inputFile, "utf-8");
725
+ }
726
+ catch {
727
+ log.error(`Could not read input file: ${args.inputFile}`);
728
+ process.exit(1);
729
+ }
730
+ // Parse YAML
731
+ let planData;
732
+ try {
733
+ planData = parseYaml(planContent);
734
+ }
735
+ catch {
736
+ log.error("Input file is not valid YAML");
737
+ process.exit(1);
738
+ }
739
+ // Validate compact plan
740
+ const validation = validateCompactPlan(planData);
582
741
  if (!validation.success) {
583
- log.error("Validation failed:");
584
- console.error(formatValidationErrors(validation.errors));
742
+ log.error("Compact plan validation failed:");
743
+ console.error(formatCompactValidationErrors(validation.errors));
585
744
  process.exit(1);
586
745
  }
587
- log.success("Plan is valid!");
746
+ // Load templates
747
+ const templates = loadTemplates();
748
+ if (args.verbose) {
749
+ log.info(`Loaded ${templates.ids().length} templates`);
750
+ }
751
+ // Validate template references
752
+ const templateErrors = validateWorkoutRefs(validation.data, templates);
753
+ if (templateErrors.length > 0) {
754
+ log.warn("Template reference warnings:");
755
+ templateErrors.forEach((e) => console.error(` - ${e}`));
756
+ }
757
+ // Expand the plan
758
+ const expanded = expandPlan(validation.data, templates);
759
+ if (args.verbose) {
760
+ log.info(`Expanded ${expanded.weeks.length} weeks`);
761
+ }
762
+ // Format output
763
+ let output;
764
+ if (args.format === "yaml") {
765
+ output = stringifyYaml(expanded);
766
+ }
767
+ else {
768
+ output = JSON.stringify(expanded, null, 2);
769
+ }
770
+ // Write output
771
+ if (args.outputFile) {
772
+ writeFileSync(args.outputFile, output);
773
+ log.success(`Expanded plan written to: ${args.outputFile}`);
774
+ }
775
+ else {
776
+ console.log(output);
777
+ }
778
+ }
779
+ // ============================================================================
780
+ // Templates Command
781
+ // ============================================================================
782
+ function runTemplates(args) {
783
+ const templates = loadTemplates();
784
+ if (args.show) {
785
+ // Show details of a specific template
786
+ const template = templates.get(args.show);
787
+ if (!template) {
788
+ log.error(`Template not found: ${args.show}`);
789
+ console.log("\nAvailable templates:");
790
+ templates.ids().forEach((id) => console.log(` - ${id}`));
791
+ process.exit(1);
792
+ }
793
+ console.log(`\n${template.name} (${template.id})`);
794
+ console.log(`${"=".repeat(template.name.length + template.id.length + 3)}`);
795
+ console.log(`\nSport: ${template.sport}`);
796
+ console.log(`Category: ${template.category}`);
797
+ console.log(`Type: ${template.type}`);
798
+ if (template.targetZone) {
799
+ console.log(`Target Zone: ${template.targetZone}`);
800
+ }
801
+ if (template.rpe) {
802
+ console.log(`RPE: ${template.rpe}`);
803
+ }
804
+ if (template.estimatedDuration) {
805
+ console.log(`Estimated Duration: ${template.estimatedDuration} min`);
806
+ }
807
+ if (template.params && Object.keys(template.params).length > 0) {
808
+ console.log("\nParameters:");
809
+ for (const [name, param] of Object.entries(template.params)) {
810
+ const required = param.required ? " (required)" : "";
811
+ const defaultVal = param.default !== undefined ? ` [default: ${param.default}]` : "";
812
+ console.log(` - ${name}: ${param.type}${required}${defaultVal}`);
813
+ if (param.description) {
814
+ console.log(` ${param.description}`);
815
+ }
816
+ }
817
+ }
818
+ console.log("\nUsage examples:");
819
+ const paramNames = template.params ? Object.keys(template.params) : [];
820
+ if (paramNames.length === 0) {
821
+ console.log(` ${template.id}`);
822
+ }
823
+ else {
824
+ const defaults = paramNames
825
+ .filter((p) => template.params[p].default !== undefined)
826
+ .map((p) => template.params[p].default);
827
+ if (defaults.length > 0) {
828
+ console.log(` ${template.id}(${defaults.join(", ")})`);
829
+ }
830
+ console.log(` ${template.id}(${paramNames.join(", ")})`);
831
+ }
832
+ console.log("\nWorkout description:");
833
+ console.log(template.humanReadable);
834
+ }
835
+ else {
836
+ // List all templates
837
+ const sport = args.sport;
838
+ const list = templates.list(sport);
839
+ if (list.length === 0) {
840
+ console.log("No templates found.");
841
+ return;
842
+ }
843
+ console.log(`\nAvailable Templates${sport ? ` (${sport})` : ""}:`);
844
+ console.log("=".repeat(40));
845
+ // Group by category
846
+ const byCategory = new Map();
847
+ for (const t of list) {
848
+ const cat = t.category;
849
+ if (!byCategory.has(cat)) {
850
+ byCategory.set(cat, []);
851
+ }
852
+ byCategory.get(cat).push(t);
853
+ }
854
+ for (const [category, categoryTemplates] of byCategory) {
855
+ console.log(`\n${category.toUpperCase()}:`);
856
+ for (const t of categoryTemplates) {
857
+ const params = t.params ? Object.keys(t.params) : [];
858
+ const paramStr = params.length > 0 ? `(${params.join(", ")})` : "";
859
+ console.log(` ${t.id}${paramStr} - ${t.name}`);
860
+ }
861
+ }
862
+ console.log(`\nTotal: ${list.length} templates`);
863
+ console.log("\nUse 'endurance-coach templates show <id>' to see template details.");
864
+ }
588
865
  }
589
866
  // ============================================================================
590
867
  // Schema Command
@@ -1060,6 +1337,12 @@ async function main() {
1060
1337
  case "validate":
1061
1338
  runValidate(args);
1062
1339
  break;
1340
+ case "expand":
1341
+ runExpand(args);
1342
+ break;
1343
+ case "templates":
1344
+ runTemplates(args);
1345
+ break;
1063
1346
  case "render":
1064
1347
  runRender(args);
1065
1348
  break;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Core Expander
3
+ *
4
+ * Converts compact training plans to expanded format for HTML rendering.
5
+ */
6
+ import type { CompactPlan } from "../schema/compact-plan.js";
7
+ import type { TemplateRegistry, InterpolationContext } from "../templates/index.js";
8
+ import type { ExpandedPlan, ExpandedWorkout, ExpansionOptions } from "./types.js";
9
+ /**
10
+ * Expand a single workout from its template reference.
11
+ */
12
+ export declare function expandWorkout(ref: string, workoutId: string, context: InterpolationContext, templates: TemplateRegistry): ExpandedWorkout;
13
+ /**
14
+ * Expand a compact plan into the full format for HTML rendering.
15
+ */
16
+ export declare function expandPlan(compact: CompactPlan, templates: TemplateRegistry, options?: ExpansionOptions): ExpandedPlan;
17
+ /**
18
+ * Validate that all workout references in a compact plan have corresponding templates.
19
+ */
20
+ export declare function validateWorkoutRefs(compact: CompactPlan, templates: TemplateRegistry): string[];