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 +2 -0
- package/dist/viewer/lib/export/erg.js +16 -14
- package/dist/viewer/lib/export/fit.js +8 -7
- package/dist/viewer/lib/export/ics.js +24 -20
- package/dist/viewer/lib/export/index.js +3 -3
- package/dist/viewer/lib/export/zwo.js +23 -20
- package/dist/viewer/lib/utils.js +6 -1
- package/dist/viewer/stores/settings.js +27 -19
- package/package.json +2 -1
- package/templates/plan-viewer.html +10 -10
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
|
|
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
|
|
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
|
-
|
|
53
|
+
const unit = step.duration?.unit ?? "minutes";
|
|
54
|
+
const value = step.duration?.value ?? 0;
|
|
55
|
+
switch (unit) {
|
|
54
56
|
case "seconds":
|
|
55
|
-
return
|
|
57
|
+
return value / 60;
|
|
56
58
|
case "minutes":
|
|
57
|
-
return
|
|
59
|
+
return value;
|
|
58
60
|
case "hours":
|
|
59
|
-
return
|
|
61
|
+
return value * 60;
|
|
60
62
|
default:
|
|
61
63
|
// For distance-based, estimate ~30km/h average
|
|
62
|
-
if (
|
|
63
|
-
return (
|
|
64
|
+
if (unit === "meters") {
|
|
65
|
+
return (value / 1000 / 30) * 60;
|
|
64
66
|
}
|
|
65
|
-
if (
|
|
66
|
-
return (
|
|
67
|
+
if (unit === "kilometers") {
|
|
68
|
+
return (value / 30) * 60;
|
|
67
69
|
}
|
|
68
|
-
return
|
|
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
|
|
130
|
-
const durationValue = getDurationValue(step.duration
|
|
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
|
|
146
|
-
fitStep.customTargetValueHigh = step.intensity.valueHigh
|
|
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 =
|
|
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(
|
|
107
|
-
`X-WR-CALDESC:${escapeIcsText(`Training plan for ${
|
|
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,
|
|
120
|
+
events.push(generateVevent(workout, day, eventName));
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
|
-
// Add a race day event
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
66
|
-
const
|
|
67
|
-
const
|
|
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
|
|
75
|
-
const
|
|
76
|
-
const
|
|
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
|
|
84
|
-
const power = intensityToDecimal(step.intensity
|
|
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
|
|
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
|
|
105
|
-
const
|
|
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
|
|
111
|
+
return steps.map((s) => generateSteadyState(s)).join("\n");
|
|
109
112
|
}
|
|
110
|
-
const onDuration = durationToSeconds(workStep.duration
|
|
111
|
-
const onPower = intensityToDecimal(workStep.intensity
|
|
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
|
|
116
|
+
? durationToSeconds(recoveryStep.duration?.value ?? 0, recoveryStep.duration?.unit ?? "minutes")
|
|
114
117
|
: 60; // Default 60s recovery
|
|
115
|
-
const offPower = recoveryStep ? intensityToDecimal(recoveryStep.intensity
|
|
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
|
package/dist/viewer/lib/utils.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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]
|
|
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.
|
|
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",
|