claude-coach 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,8 @@ Workouts can be exported as simple calendar events (.ics), Zwift (.zwo), Garmin
12
12
 
13
13
  I happen to work at Anthropic, so this tool is optimized for Claude. To use this tool, you need access to Claude.ai or Claude Code with network access for Skills. Depending on user/admin settings, Skills may have full, partial, or no network access.
14
14
 
15
+ Syncing all your Strava activities and creating a tailored training plan takes ca. 15 minutes.
16
+
15
17
  ### Installing the Skill
16
18
 
17
19
  First, [download the latest skill from GitHub Releases](https://github.com/felixrieseberg/claude-coach/releases/latest/download/coach-skill.zip).
@@ -31,12 +31,12 @@ function generateDataPoints(structure) {
31
31
  const points = [];
32
32
  let currentMinute = 0;
33
33
  const addStep = (step) => {
34
- const percent = intensityToPercent(step.intensity.value);
34
+ const percent = intensityToPercent(step.intensity?.value ?? 50);
35
35
  const durationMinutes = getDurationMinutes(step);
36
36
  // Add start point
37
37
  points.push([currentMinute, percent]);
38
38
  // For ramps, add intermediate points
39
- if (step.intensity.valueLow !== undefined && step.intensity.valueHigh !== undefined) {
39
+ if (step.intensity?.valueLow !== undefined && step.intensity?.valueHigh !== undefined) {
40
40
  const startPercent = intensityToPercent(step.intensity.valueLow);
41
41
  const endPercent = intensityToPercent(step.intensity.valueHigh);
42
42
  points[points.length - 1] = [currentMinute, startPercent];
@@ -50,22 +50,24 @@ function generateDataPoints(structure) {
50
50
  }
51
51
  };
52
52
  const getDurationMinutes = (step) => {
53
- switch (step.duration.unit) {
53
+ const unit = step.duration?.unit ?? "minutes";
54
+ const value = step.duration?.value ?? 0;
55
+ switch (unit) {
54
56
  case "seconds":
55
- return step.duration.value / 60;
57
+ return value / 60;
56
58
  case "minutes":
57
- return step.duration.value;
59
+ return value;
58
60
  case "hours":
59
- return step.duration.value * 60;
61
+ return value * 60;
60
62
  default:
61
63
  // For distance-based, estimate ~30km/h average
62
- if (step.duration.unit === "meters") {
63
- return (step.duration.value / 1000 / 30) * 60;
64
+ if (unit === "meters") {
65
+ return (value / 1000 / 30) * 60;
64
66
  }
65
- if (step.duration.unit === "kilometers") {
66
- return (step.duration.value / 30) * 60;
67
+ if (unit === "kilometers") {
68
+ return (value / 30) * 60;
67
69
  }
68
- return step.duration.value;
70
+ return value;
69
71
  }
70
72
  };
71
73
  // Process warmup
@@ -75,11 +77,11 @@ function generateDataPoints(structure) {
75
77
  }
76
78
  }
77
79
  // Process main set
78
- for (const item of structure.main) {
80
+ for (const item of structure.main ?? []) {
79
81
  if ("repeats" in item) {
80
82
  const intervalSet = item;
81
- for (let i = 0; i < intervalSet.repeats; i++) {
82
- for (const step of intervalSet.steps) {
83
+ for (let i = 0; i < (intervalSet.repeats ?? 1); i++) {
84
+ for (const step of intervalSet.steps ?? []) {
83
85
  addStep(step);
84
86
  }
85
87
  }
@@ -126,8 +126,8 @@ function generateStepsFromStructure(structure) {
126
126
  let stepIndex = 0;
127
127
  // Helper to add a step
128
128
  const addStep = (step, isPartOfRepeat = false) => {
129
- const durationType = getDurationType(step.duration.unit);
130
- const durationValue = getDurationValue(step.duration.value, step.duration.unit);
129
+ const durationType = getDurationType(step.duration?.unit ?? "minutes");
130
+ const durationValue = getDurationValue(step.duration?.value ?? 0, step.duration?.unit ?? "minutes");
131
131
  const fitStep = {
132
132
  messageIndex: stepIndex,
133
133
  workoutStepName: step.name || "",
@@ -138,12 +138,13 @@ function generateStepsFromStructure(structure) {
138
138
  };
139
139
  // Add target based on intensity unit
140
140
  if (step.intensity) {
141
+ const intensityValue = step.intensity.value ?? 50;
141
142
  switch (step.intensity.unit) {
142
143
  case "percent_ftp":
143
144
  fitStep.targetType = "power";
144
145
  fitStep.targetValue = 0;
145
- fitStep.customTargetValueLow = step.intensity.valueLow || step.intensity.value - 5;
146
- fitStep.customTargetValueHigh = step.intensity.valueHigh || step.intensity.value + 5;
146
+ fitStep.customTargetValueLow = step.intensity.valueLow ?? intensityValue - 5;
147
+ fitStep.customTargetValueHigh = step.intensity.valueHigh ?? intensityValue + 5;
147
148
  break;
148
149
  case "percent_lthr":
149
150
  case "hr_zone":
@@ -156,7 +157,7 @@ function generateStepsFromStructure(structure) {
156
157
  }
157
158
  else {
158
159
  // Use zone as target value (1-5)
159
- fitStep.targetValue = step.intensity.value;
160
+ fitStep.targetValue = intensityValue;
160
161
  }
161
162
  break;
162
163
  case "rpe":
@@ -172,8 +173,8 @@ function generateStepsFromStructure(structure) {
172
173
  }
173
174
  // Add cadence target if present
174
175
  if (step.cadence) {
175
- fitStep.customTargetCadenceLow = step.cadence.low;
176
- fitStep.customTargetCadenceHigh = step.cadence.high;
176
+ fitStep.customTargetCadenceLow = step.cadence.low ?? 80;
177
+ fitStep.customTargetCadenceHigh = step.cadence.high ?? 100;
177
178
  }
178
179
  steps.push(fitStep);
179
180
  stepIndex++;
@@ -97,42 +97,46 @@ function generateVevent(workout, day, planName) {
97
97
  */
98
98
  export function generateIcs(plan) {
99
99
  const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
100
+ const eventName = plan.meta?.event ?? "Training Plan";
101
+ const eventDate = plan.meta?.eventDate ?? "";
100
102
  const header = [
101
103
  "BEGIN:VCALENDAR",
102
104
  "VERSION:2.0",
103
105
  "PRODID:-//Claude Coach//Training Plan//EN",
104
106
  "CALSCALE:GREGORIAN",
105
107
  "METHOD:PUBLISH",
106
- `X-WR-CALNAME:${escapeIcsText(plan.meta.event)} Training`,
107
- `X-WR-CALDESC:${escapeIcsText(`Training plan for ${plan.meta.event} on ${plan.meta.eventDate}`)}`,
108
+ `X-WR-CALNAME:${escapeIcsText(eventName)} Training`,
109
+ `X-WR-CALDESC:${escapeIcsText(`Training plan for ${eventName} on ${eventDate}`)}`,
108
110
  ].join("\r\n");
109
111
  const events = [];
110
112
  // Generate events for all workouts
111
- for (const week of plan.weeks) {
112
- for (const day of week.days) {
113
- for (const workout of day.workouts) {
113
+ for (const week of plan.weeks ?? []) {
114
+ for (const day of week.days ?? []) {
115
+ for (const workout of day.workouts ?? []) {
114
116
  // Skip rest days without actual workouts
115
117
  if (workout.sport === "rest" && !workout.name) {
116
118
  continue;
117
119
  }
118
- events.push(generateVevent(workout, day, plan.meta.event));
120
+ events.push(generateVevent(workout, day, eventName));
119
121
  }
120
122
  }
121
123
  }
122
- // Add a race day event
123
- const raceDayEvent = [
124
- "BEGIN:VEVENT",
125
- `UID:race-day@claude-coach`,
126
- `DTSTAMP:${now}`,
127
- `DTSTART;VALUE=DATE:${formatIcsDate(plan.meta.eventDate)}`,
128
- `DTEND;VALUE=DATE:${formatIcsDate(plan.meta.eventDate)}`,
129
- foldLine(`SUMMARY:\u{1F3C6} RACE DAY: ${escapeIcsText(plan.meta.event)}`),
130
- foldLine(`DESCRIPTION:${escapeIcsText(`Race day for ${plan.meta.event}!`)}`),
131
- `CATEGORIES:race,${escapeIcsText(plan.meta.event)}`,
132
- `TRANSP:OPAQUE`, // Block time for race day
133
- "END:VEVENT",
134
- ].join("\r\n");
135
- events.push(raceDayEvent);
124
+ // Add a race day event if we have a date
125
+ if (eventDate) {
126
+ const raceDayEvent = [
127
+ "BEGIN:VEVENT",
128
+ `UID:race-day@claude-coach`,
129
+ `DTSTAMP:${now}`,
130
+ `DTSTART;VALUE=DATE:${formatIcsDate(eventDate)}`,
131
+ `DTEND;VALUE=DATE:${formatIcsDate(eventDate)}`,
132
+ foldLine(`SUMMARY:\u{1F3C6} RACE DAY: ${escapeIcsText(eventName)}`),
133
+ foldLine(`DESCRIPTION:${escapeIcsText(`Race day for ${eventName}!`)}`),
134
+ `CATEGORIES:race,${escapeIcsText(eventName)}`,
135
+ `TRANSP:OPAQUE`, // Block time for race day
136
+ "END:VEVENT",
137
+ ].join("\r\n");
138
+ events.push(raceDayEvent);
139
+ }
136
140
  const footer = "END:VCALENDAR";
137
141
  return header + "\r\n" + events.join("\r\n") + "\r\n" + footer;
138
142
  }
@@ -177,9 +177,9 @@ export async function exportAllWorkouts(plan, format, settings) {
177
177
  let exported = 0;
178
178
  let skipped = 0;
179
179
  const zip = new JSZip();
180
- for (const week of plan.weeks) {
181
- for (const day of week.days) {
182
- for (const workout of day.workouts) {
180
+ for (const week of plan.weeks ?? []) {
181
+ for (const day of week.days ?? []) {
182
+ for (const workout of day.workouts ?? []) {
183
183
  // Skip rest days
184
184
  if (workout.sport === "rest") {
185
185
  skipped++;
@@ -62,34 +62,36 @@ function getZwoSportType(sport) {
62
62
  * Generate a warmup segment
63
63
  */
64
64
  function generateWarmup(step) {
65
- const duration = durationToSeconds(step.duration.value, step.duration.unit);
66
- const startPower = intensityToDecimal(step.intensity.valueLow || step.intensity.value * 0.6);
67
- const endPower = intensityToDecimal(step.intensity.valueHigh || step.intensity.value);
65
+ const duration = durationToSeconds(step.duration?.value ?? 0, step.duration?.unit ?? "minutes");
66
+ const intensityValue = step.intensity?.value ?? 50;
67
+ const startPower = intensityToDecimal(step.intensity?.valueLow ?? intensityValue * 0.6);
68
+ const endPower = intensityToDecimal(step.intensity?.valueHigh ?? intensityValue);
68
69
  return ` <Warmup Duration="${duration}" PowerLow="${startPower.toFixed(2)}" PowerHigh="${endPower.toFixed(2)}"/>`;
69
70
  }
70
71
  /**
71
72
  * Generate a cooldown segment
72
73
  */
73
74
  function generateCooldown(step) {
74
- const duration = durationToSeconds(step.duration.value, step.duration.unit);
75
- const startPower = intensityToDecimal(step.intensity.valueHigh || step.intensity.value);
76
- const endPower = intensityToDecimal(step.intensity.valueLow || step.intensity.value * 0.5);
75
+ const duration = durationToSeconds(step.duration?.value ?? 0, step.duration?.unit ?? "minutes");
76
+ const intensityValue = step.intensity?.value ?? 50;
77
+ const startPower = intensityToDecimal(step.intensity?.valueHigh ?? intensityValue);
78
+ const endPower = intensityToDecimal(step.intensity?.valueLow ?? intensityValue * 0.5);
77
79
  return ` <Cooldown Duration="${duration}" PowerLow="${endPower.toFixed(2)}" PowerHigh="${startPower.toFixed(2)}"/>`;
78
80
  }
79
81
  /**
80
82
  * Generate a steady state segment
81
83
  */
82
84
  function generateSteadyState(step, isRamp = false) {
83
- const duration = durationToSeconds(step.duration.value, step.duration.unit);
84
- const power = intensityToDecimal(step.intensity.value);
85
+ const duration = durationToSeconds(step.duration?.value ?? 0, step.duration?.unit ?? "minutes");
86
+ const power = intensityToDecimal(step.intensity?.value ?? 50);
85
87
  let cadenceAttr = "";
86
88
  if (step.cadence) {
87
- cadenceAttr = ` Cadence="${step.cadence.low}"`;
89
+ cadenceAttr = ` Cadence="${step.cadence.low ?? 90}"`;
88
90
  if (step.cadence.high !== step.cadence.low) {
89
- cadenceAttr = ` CadenceLow="${step.cadence.low}" CadenceHigh="${step.cadence.high}"`;
91
+ cadenceAttr = ` CadenceLow="${step.cadence.low ?? 90}" CadenceHigh="${step.cadence.high ?? 100}"`;
90
92
  }
91
93
  }
92
- if (isRamp && step.intensity.valueLow !== undefined && step.intensity.valueHigh !== undefined) {
94
+ if (isRamp && step.intensity?.valueLow !== undefined && step.intensity?.valueHigh !== undefined) {
93
95
  const powerLow = intensityToDecimal(step.intensity.valueLow);
94
96
  const powerHigh = intensityToDecimal(step.intensity.valueHigh);
95
97
  return ` <Ramp Duration="${duration}" PowerLow="${powerLow.toFixed(2)}" PowerHigh="${powerHigh.toFixed(2)}"${cadenceAttr}/>`;
@@ -101,23 +103,24 @@ function generateSteadyState(step, isRamp = false) {
101
103
  */
102
104
  function generateIntervalSet(intervalSet) {
103
105
  // Find work and recovery steps
104
- const workStep = intervalSet.steps.find((s) => s.type === "work");
105
- const recoveryStep = intervalSet.steps.find((s) => s.type === "recovery" || s.type === "rest");
106
+ const steps = intervalSet.steps ?? [];
107
+ const workStep = steps.find((s) => s.type === "work");
108
+ const recoveryStep = steps.find((s) => s.type === "recovery" || s.type === "rest");
106
109
  if (!workStep) {
107
110
  // Just generate steady states if no work step found
108
- return intervalSet.steps.map((s) => generateSteadyState(s)).join("\n");
111
+ return steps.map((s) => generateSteadyState(s)).join("\n");
109
112
  }
110
- const onDuration = durationToSeconds(workStep.duration.value, workStep.duration.unit);
111
- const onPower = intensityToDecimal(workStep.intensity.value);
113
+ const onDuration = durationToSeconds(workStep.duration?.value ?? 0, workStep.duration?.unit ?? "minutes");
114
+ const onPower = intensityToDecimal(workStep.intensity?.value ?? 100);
112
115
  const offDuration = recoveryStep
113
- ? durationToSeconds(recoveryStep.duration.value, recoveryStep.duration.unit)
116
+ ? durationToSeconds(recoveryStep.duration?.value ?? 0, recoveryStep.duration?.unit ?? "minutes")
114
117
  : 60; // Default 60s recovery
115
- const offPower = recoveryStep ? intensityToDecimal(recoveryStep.intensity.value) : 0.5; // Default 50% recovery
118
+ const offPower = recoveryStep ? intensityToDecimal(recoveryStep.intensity?.value ?? 50) : 0.5; // Default 50% recovery
116
119
  let cadenceAttr = "";
117
120
  if (workStep.cadence) {
118
- cadenceAttr = ` Cadence="${workStep.cadence.low}"`;
121
+ cadenceAttr = ` Cadence="${workStep.cadence.low ?? 90}"`;
119
122
  }
120
- return ` <IntervalsT Repeat="${intervalSet.repeats}" OnDuration="${onDuration}" OffDuration="${offDuration}" OnPower="${onPower.toFixed(2)}" OffPower="${offPower.toFixed(2)}"${cadenceAttr}/>`;
123
+ return ` <IntervalsT Repeat="${intervalSet.repeats ?? 1}" OnDuration="${onDuration}" OffDuration="${offDuration}" OnPower="${onPower.toFixed(2)}" OffPower="${offPower.toFixed(2)}"${cadenceAttr}/>`;
121
124
  }
122
125
  /**
123
126
  * Generate workout segments from structured workout
@@ -89,7 +89,12 @@ export function formatDateISO(date) {
89
89
  }
90
90
  // Parse ISO date string to Date object (at midnight local time)
91
91
  export function parseDate(dateStr) {
92
- const [year, month, day] = dateStr.split("-").map(Number);
92
+ if (!dateStr)
93
+ return new Date();
94
+ const parts = dateStr.split("-").map(Number);
95
+ const year = parts[0] ?? new Date().getFullYear();
96
+ const month = parts[1] ?? 1;
97
+ const day = parts[2] ?? 1;
93
98
  return new Date(year, month - 1, day);
94
99
  }
95
100
  export function getSportColor(sport) {
@@ -65,12 +65,14 @@ export function loadSettings() {
65
65
  const zones = planData.zones;
66
66
  if (zones?.run?.hr) {
67
67
  settings.run.lthr = zones.run.hr.lthr;
68
- settings.run.hrZones = zones.run.hr.zones.map((z) => ({
69
- zone: z.zone,
70
- name: z.name,
71
- low: z.hrLow,
72
- high: z.hrHigh,
73
- }));
68
+ if (zones.run.hr.zones) {
69
+ settings.run.hrZones = zones.run.hr.zones.map((z) => ({
70
+ zone: z.zone,
71
+ name: z.name,
72
+ low: z.hrLow,
73
+ high: z.hrHigh,
74
+ }));
75
+ }
74
76
  }
75
77
  if (zones?.run?.pace) {
76
78
  settings.run.thresholdPace = zones.run.pace.thresholdPace || settings.run.thresholdPace;
@@ -84,21 +86,25 @@ export function loadSettings() {
84
86
  }
85
87
  if (zones?.bike?.hr) {
86
88
  settings.bike.lthr = zones.bike.hr.lthr;
87
- settings.bike.hrZones = zones.bike.hr.zones.map((z) => ({
88
- zone: z.zone,
89
- name: z.name,
90
- low: z.hrLow,
91
- high: z.hrHigh,
92
- }));
89
+ if (zones.bike.hr.zones) {
90
+ settings.bike.hrZones = zones.bike.hr.zones.map((z) => ({
91
+ zone: z.zone,
92
+ name: z.name,
93
+ low: z.hrLow,
94
+ high: z.hrHigh,
95
+ }));
96
+ }
93
97
  }
94
98
  if (zones?.bike?.power) {
95
99
  settings.bike.ftp = zones.bike.power.ftp;
96
- settings.bike.powerZones = zones.bike.power.zones.map((z) => ({
97
- zone: z.zone,
98
- name: z.name,
99
- low: z.wattsLow,
100
- high: z.wattsHigh,
101
- }));
100
+ if (zones.bike.power.zones) {
101
+ settings.bike.powerZones = zones.bike.power.zones.map((z) => ({
102
+ zone: z.zone,
103
+ name: z.name,
104
+ low: z.wattsLow,
105
+ high: z.wattsHigh,
106
+ }));
107
+ }
102
108
  }
103
109
  if (zones?.swim) {
104
110
  settings.swim.css = zones.swim.css || settings.swim.css;
@@ -136,8 +142,10 @@ export function saveSettings(settings) {
136
142
  }
137
143
  // Utility functions
138
144
  export function paceToSeconds(pace) {
145
+ if (!pace)
146
+ return 0;
139
147
  const parts = pace.split(":");
140
- return parseInt(parts[0]) * 60 + parseInt(parts[1] || "0");
148
+ return parseInt(parts[0] ?? "0") * 60 + parseInt(parts[1] ?? "0");
141
149
  }
142
150
  export function secondsToPace(seconds) {
143
151
  const mins = Math.floor(seconds / 60);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-coach",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "author": "Felix Rieseberg <felix@felixrieseberg.com> (https://felixrieseberg.com)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  "build:ts": "tsc && cp src/db/schema.sql dist/db/",
21
21
  "build:viewer": "vite build --config vite.config.viewer.ts",
22
22
  "build:skill": "mkdir -p dist && cd skill && zip -r ../dist/coach-skill.zip . -x '*.DS_Store' && echo 'Skill packaged to dist/coach-skill.zip'",
23
+ "build:website": "node scripts/build-website.js",
23
24
  "dev": "tsx watch src/cli.ts",
24
25
  "dev:viewer": "vite --config vite.config.viewer.ts",
25
26
  "test": "vitest",