claude-coach 0.0.1
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/LICENSE.md +21 -0
- package/README.md +84 -0
- package/bin/claude-coach.js +10 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +277 -0
- package/dist/db/client.d.ts +4 -0
- package/dist/db/client.js +45 -0
- package/dist/db/migrate.d.ts +1 -0
- package/dist/db/migrate.js +14 -0
- package/dist/lib/config.d.ts +27 -0
- package/dist/lib/config.js +86 -0
- package/dist/lib/logging.d.ts +13 -0
- package/dist/lib/logging.js +28 -0
- package/dist/schema/training-plan.d.ts +288 -0
- package/dist/schema/training-plan.js +88 -0
- package/dist/strava/api.d.ts +5 -0
- package/dist/strava/api.js +63 -0
- package/dist/strava/oauth.d.ts +4 -0
- package/dist/strava/oauth.js +113 -0
- package/dist/strava/types.d.ts +46 -0
- package/dist/strava/types.js +1 -0
- package/dist/viewer/lib/export/erg.d.ts +26 -0
- package/dist/viewer/lib/export/erg.js +206 -0
- package/dist/viewer/lib/export/fit.d.ts +25 -0
- package/dist/viewer/lib/export/fit.js +307 -0
- package/dist/viewer/lib/export/ics.d.ts +13 -0
- package/dist/viewer/lib/export/ics.js +138 -0
- package/dist/viewer/lib/export/index.d.ts +50 -0
- package/dist/viewer/lib/export/index.js +229 -0
- package/dist/viewer/lib/export/zwo.d.ts +21 -0
- package/dist/viewer/lib/export/zwo.js +230 -0
- package/dist/viewer/lib/utils.d.ts +14 -0
- package/dist/viewer/lib/utils.js +118 -0
- package/dist/viewer/main.d.ts +5 -0
- package/dist/viewer/main.js +6 -0
- package/dist/viewer/stores/changes.d.ts +21 -0
- package/dist/viewer/stores/changes.js +49 -0
- package/dist/viewer/stores/plan.d.ts +4 -0
- package/dist/viewer/stores/plan.js +19 -0
- package/dist/viewer/stores/settings.d.ts +53 -0
- package/dist/viewer/stores/settings.js +207 -0
- package/package.json +55 -0
- package/templates/plan-viewer.html +70 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ERG/MRC Export
|
|
3
|
+
*
|
|
4
|
+
* Generates ERG/MRC workout files for indoor cycling trainers.
|
|
5
|
+
* Widely supported by TrainerRoad, Zwift, PerfPRO, Golden Cheetah, and others.
|
|
6
|
+
*
|
|
7
|
+
* - ERG format: Uses absolute watts
|
|
8
|
+
* - MRC format: Uses percentage of FTP (more portable)
|
|
9
|
+
*
|
|
10
|
+
* We generate MRC format since it scales to each user's FTP.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Check if a sport is supported by ERG/MRC export
|
|
14
|
+
* Only cycling workouts make sense for trainer files
|
|
15
|
+
*/
|
|
16
|
+
export function isErgSupported(sport) {
|
|
17
|
+
return sport === "bike";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Convert intensity to percentage of FTP
|
|
21
|
+
*/
|
|
22
|
+
function intensityToPercent(intensity) {
|
|
23
|
+
// Intensity is stored as percentage (e.g., 75 for 75%)
|
|
24
|
+
return intensity;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Generate data points from structured workout
|
|
28
|
+
* Returns array of [minutes, percent] tuples
|
|
29
|
+
*/
|
|
30
|
+
function generateDataPoints(structure) {
|
|
31
|
+
const points = [];
|
|
32
|
+
let currentMinute = 0;
|
|
33
|
+
const addStep = (step) => {
|
|
34
|
+
const percent = intensityToPercent(step.intensity.value);
|
|
35
|
+
const durationMinutes = getDurationMinutes(step);
|
|
36
|
+
// Add start point
|
|
37
|
+
points.push([currentMinute, percent]);
|
|
38
|
+
// For ramps, add intermediate points
|
|
39
|
+
if (step.intensity.valueLow !== undefined && step.intensity.valueHigh !== undefined) {
|
|
40
|
+
const startPercent = intensityToPercent(step.intensity.valueLow);
|
|
41
|
+
const endPercent = intensityToPercent(step.intensity.valueHigh);
|
|
42
|
+
points[points.length - 1] = [currentMinute, startPercent];
|
|
43
|
+
currentMinute += durationMinutes;
|
|
44
|
+
points.push([currentMinute, endPercent]);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
currentMinute += durationMinutes;
|
|
48
|
+
// Add end point at same intensity (creates flat segment)
|
|
49
|
+
points.push([currentMinute, percent]);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const getDurationMinutes = (step) => {
|
|
53
|
+
switch (step.duration.unit) {
|
|
54
|
+
case "seconds":
|
|
55
|
+
return step.duration.value / 60;
|
|
56
|
+
case "minutes":
|
|
57
|
+
return step.duration.value;
|
|
58
|
+
case "hours":
|
|
59
|
+
return step.duration.value * 60;
|
|
60
|
+
default:
|
|
61
|
+
// For distance-based, estimate ~30km/h average
|
|
62
|
+
if (step.duration.unit === "meters") {
|
|
63
|
+
return (step.duration.value / 1000 / 30) * 60;
|
|
64
|
+
}
|
|
65
|
+
if (step.duration.unit === "kilometers") {
|
|
66
|
+
return (step.duration.value / 30) * 60;
|
|
67
|
+
}
|
|
68
|
+
return step.duration.value;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
// Process warmup
|
|
72
|
+
if (structure.warmup) {
|
|
73
|
+
for (const step of structure.warmup) {
|
|
74
|
+
addStep(step);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Process main set
|
|
78
|
+
for (const item of structure.main) {
|
|
79
|
+
if ("repeats" in item) {
|
|
80
|
+
const intervalSet = item;
|
|
81
|
+
for (let i = 0; i < intervalSet.repeats; i++) {
|
|
82
|
+
for (const step of intervalSet.steps) {
|
|
83
|
+
addStep(step);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
addStep(item);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Process cooldown
|
|
92
|
+
if (structure.cooldown) {
|
|
93
|
+
for (const step of structure.cooldown) {
|
|
94
|
+
addStep(step);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return points;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Generate simple data points when no structure is provided
|
|
101
|
+
*/
|
|
102
|
+
function generateSimpleDataPoints(workout) {
|
|
103
|
+
const totalMinutes = workout.durationMinutes || 60;
|
|
104
|
+
const points = [];
|
|
105
|
+
// Determine main intensity based on workout type
|
|
106
|
+
let mainPercent = 65;
|
|
107
|
+
switch (workout.type) {
|
|
108
|
+
case "recovery":
|
|
109
|
+
mainPercent = 55;
|
|
110
|
+
break;
|
|
111
|
+
case "endurance":
|
|
112
|
+
mainPercent = 65;
|
|
113
|
+
break;
|
|
114
|
+
case "tempo":
|
|
115
|
+
mainPercent = 80;
|
|
116
|
+
break;
|
|
117
|
+
case "threshold":
|
|
118
|
+
mainPercent = 95;
|
|
119
|
+
break;
|
|
120
|
+
case "vo2max":
|
|
121
|
+
mainPercent = 110;
|
|
122
|
+
break;
|
|
123
|
+
case "intervals":
|
|
124
|
+
mainPercent = 85;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
// Warmup (10% of total, ramp from 40% to 65%)
|
|
128
|
+
const warmupMinutes = Math.min(15, Math.max(5, Math.round(totalMinutes * 0.1)));
|
|
129
|
+
points.push([0, 40]);
|
|
130
|
+
points.push([warmupMinutes, 65]);
|
|
131
|
+
// Main set
|
|
132
|
+
const cooldownMinutes = Math.min(10, Math.max(5, Math.round(totalMinutes * 0.1)));
|
|
133
|
+
const mainMinutes = totalMinutes - warmupMinutes - cooldownMinutes;
|
|
134
|
+
points.push([warmupMinutes, mainPercent]);
|
|
135
|
+
points.push([warmupMinutes + mainMinutes, mainPercent]);
|
|
136
|
+
// Cooldown (ramp from 60% to 40%)
|
|
137
|
+
points.push([warmupMinutes + mainMinutes, 60]);
|
|
138
|
+
points.push([totalMinutes, 40]);
|
|
139
|
+
return points;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Generate a complete MRC file for a workout
|
|
143
|
+
*/
|
|
144
|
+
export function generateMrc(workout, _settings) {
|
|
145
|
+
if (!isErgSupported(workout.sport)) {
|
|
146
|
+
throw new Error(`ERG/MRC export only supports bike workouts`);
|
|
147
|
+
}
|
|
148
|
+
const dataPoints = workout.structure
|
|
149
|
+
? generateDataPoints(workout.structure)
|
|
150
|
+
: generateSimpleDataPoints(workout);
|
|
151
|
+
// Build description from workout info
|
|
152
|
+
let description = workout.name;
|
|
153
|
+
if (workout.description) {
|
|
154
|
+
description += ` - ${workout.description}`;
|
|
155
|
+
}
|
|
156
|
+
const lines = [
|
|
157
|
+
"[COURSE HEADER]",
|
|
158
|
+
`VERSION = 2`,
|
|
159
|
+
`UNITS = ENGLISH`,
|
|
160
|
+
`DESCRIPTION = ${description.replace(/[\r\n]/g, " ")}`,
|
|
161
|
+
`FILE NAME = ${workout.name}`,
|
|
162
|
+
`MINUTES PERCENT`,
|
|
163
|
+
"[END COURSE HEADER]",
|
|
164
|
+
"[COURSE DATA]",
|
|
165
|
+
];
|
|
166
|
+
// Add data points
|
|
167
|
+
for (const [minutes, percent] of dataPoints) {
|
|
168
|
+
lines.push(`${minutes.toFixed(2)}\t${percent.toFixed(0)}`);
|
|
169
|
+
}
|
|
170
|
+
lines.push("[END COURSE DATA]");
|
|
171
|
+
return lines.join("\n");
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Generate ERG file (absolute watts) - requires FTP
|
|
175
|
+
*/
|
|
176
|
+
export function generateErg(workout, settings) {
|
|
177
|
+
if (!isErgSupported(workout.sport)) {
|
|
178
|
+
throw new Error(`ERG/MRC export only supports bike workouts`);
|
|
179
|
+
}
|
|
180
|
+
const ftp = settings.bike?.ftp || 200; // Default to 200W if not set
|
|
181
|
+
const dataPoints = workout.structure
|
|
182
|
+
? generateDataPoints(workout.structure)
|
|
183
|
+
: generateSimpleDataPoints(workout);
|
|
184
|
+
let description = workout.name;
|
|
185
|
+
if (workout.description) {
|
|
186
|
+
description += ` - ${workout.description}`;
|
|
187
|
+
}
|
|
188
|
+
const lines = [
|
|
189
|
+
"[COURSE HEADER]",
|
|
190
|
+
`VERSION = 2`,
|
|
191
|
+
`UNITS = ENGLISH`,
|
|
192
|
+
`DESCRIPTION = ${description.replace(/[\r\n]/g, " ")}`,
|
|
193
|
+
`FILE NAME = ${workout.name}`,
|
|
194
|
+
`FTP = ${ftp}`,
|
|
195
|
+
`MINUTES WATTS`,
|
|
196
|
+
"[END COURSE HEADER]",
|
|
197
|
+
"[COURSE DATA]",
|
|
198
|
+
];
|
|
199
|
+
// Add data points converted to watts
|
|
200
|
+
for (const [minutes, percent] of dataPoints) {
|
|
201
|
+
const watts = Math.round((percent / 100) * ftp);
|
|
202
|
+
lines.push(`${minutes.toFixed(2)}\t${watts}`);
|
|
203
|
+
}
|
|
204
|
+
lines.push("[END COURSE DATA]");
|
|
205
|
+
return lines.join("\n");
|
|
206
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FIT (Garmin) Workout Export
|
|
3
|
+
*
|
|
4
|
+
* Generates Garmin FIT workout files that can be uploaded to Garmin Connect.
|
|
5
|
+
* Uses the official @garmin/fitsdk package for binary FIT encoding.
|
|
6
|
+
*
|
|
7
|
+
* How to import to Garmin:
|
|
8
|
+
* - Upload to Garmin Connect (connect.garmin.com) -> Training -> Workouts -> Import
|
|
9
|
+
* - The workout will sync to compatible Garmin devices
|
|
10
|
+
*/
|
|
11
|
+
import type { Workout, Sport } from "../../../schema/training-plan.js";
|
|
12
|
+
import type { Settings } from "../../stores/settings.js";
|
|
13
|
+
/**
|
|
14
|
+
* Check if the FIT SDK is available at runtime
|
|
15
|
+
* Always returns true since the SDK is now bundled
|
|
16
|
+
*/
|
|
17
|
+
export declare function isFitSdkAvailable(): Promise<boolean>;
|
|
18
|
+
/**
|
|
19
|
+
* Check if a sport is supported by FIT export
|
|
20
|
+
*/
|
|
21
|
+
export declare function isFitSupported(sport: Sport): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Generate a complete FIT workout file
|
|
24
|
+
*/
|
|
25
|
+
export declare function generateFit(workout: Workout, _settings: Settings): Promise<Uint8Array>;
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FIT (Garmin) Workout Export
|
|
3
|
+
*
|
|
4
|
+
* Generates Garmin FIT workout files that can be uploaded to Garmin Connect.
|
|
5
|
+
* Uses the official @garmin/fitsdk package for binary FIT encoding.
|
|
6
|
+
*
|
|
7
|
+
* How to import to Garmin:
|
|
8
|
+
* - Upload to Garmin Connect (connect.garmin.com) -> Training -> Workouts -> Import
|
|
9
|
+
* - The workout will sync to compatible Garmin devices
|
|
10
|
+
*/
|
|
11
|
+
import { Encoder, Profile } from "@garmin/fitsdk";
|
|
12
|
+
/**
|
|
13
|
+
* Check if the FIT SDK is available at runtime
|
|
14
|
+
* Always returns true since the SDK is now bundled
|
|
15
|
+
*/
|
|
16
|
+
export async function isFitSdkAvailable() {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Check if a sport is supported by FIT export
|
|
21
|
+
*/
|
|
22
|
+
export function isFitSupported(sport) {
|
|
23
|
+
// FIT supports most sports, excluding rest and race-specific
|
|
24
|
+
return sport !== "rest" && sport !== "race";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Map our sport types to FIT sport enum values
|
|
28
|
+
*/
|
|
29
|
+
function getFitSport(sport) {
|
|
30
|
+
switch (sport) {
|
|
31
|
+
case "swim":
|
|
32
|
+
return "swimming";
|
|
33
|
+
case "bike":
|
|
34
|
+
return "cycling";
|
|
35
|
+
case "run":
|
|
36
|
+
return "running";
|
|
37
|
+
case "strength":
|
|
38
|
+
return "training";
|
|
39
|
+
case "brick":
|
|
40
|
+
return "multisport";
|
|
41
|
+
default:
|
|
42
|
+
return "generic";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Map our sport types to FIT sub_sport enum values
|
|
47
|
+
*/
|
|
48
|
+
function getFitSubSport(sport) {
|
|
49
|
+
switch (sport) {
|
|
50
|
+
case "swim":
|
|
51
|
+
return "lap_swimming";
|
|
52
|
+
case "bike":
|
|
53
|
+
return "road";
|
|
54
|
+
case "run":
|
|
55
|
+
return "road";
|
|
56
|
+
case "strength":
|
|
57
|
+
return "strength_training";
|
|
58
|
+
case "brick":
|
|
59
|
+
return "triathlon";
|
|
60
|
+
default:
|
|
61
|
+
return "generic";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Get workout step intensity/duration type based on step type
|
|
66
|
+
*/
|
|
67
|
+
function getStepIntensity(stepType) {
|
|
68
|
+
switch (stepType) {
|
|
69
|
+
case "warmup":
|
|
70
|
+
return "warmup";
|
|
71
|
+
case "cooldown":
|
|
72
|
+
return "cooldown";
|
|
73
|
+
case "rest":
|
|
74
|
+
return "rest";
|
|
75
|
+
case "recovery":
|
|
76
|
+
return "recovery";
|
|
77
|
+
case "work":
|
|
78
|
+
return "active";
|
|
79
|
+
default:
|
|
80
|
+
return "active";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Convert duration to FIT workout step duration
|
|
85
|
+
*/
|
|
86
|
+
function getDurationValue(value, unit) {
|
|
87
|
+
switch (unit) {
|
|
88
|
+
case "seconds":
|
|
89
|
+
return value * 1000; // FIT uses milliseconds
|
|
90
|
+
case "minutes":
|
|
91
|
+
return value * 60 * 1000;
|
|
92
|
+
case "hours":
|
|
93
|
+
return value * 3600 * 1000;
|
|
94
|
+
case "meters":
|
|
95
|
+
return value * 100; // FIT uses centimeters
|
|
96
|
+
case "kilometers":
|
|
97
|
+
return value * 100000;
|
|
98
|
+
case "miles":
|
|
99
|
+
return value * 160934; // cm per mile
|
|
100
|
+
default:
|
|
101
|
+
return value * 1000;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Get duration type for FIT
|
|
106
|
+
*/
|
|
107
|
+
function getDurationType(unit) {
|
|
108
|
+
switch (unit) {
|
|
109
|
+
case "seconds":
|
|
110
|
+
case "minutes":
|
|
111
|
+
case "hours":
|
|
112
|
+
return "time";
|
|
113
|
+
case "meters":
|
|
114
|
+
case "kilometers":
|
|
115
|
+
case "miles":
|
|
116
|
+
return "distance";
|
|
117
|
+
default:
|
|
118
|
+
return "time";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Generate workout steps from structured workout
|
|
123
|
+
*/
|
|
124
|
+
function generateStepsFromStructure(structure) {
|
|
125
|
+
const steps = [];
|
|
126
|
+
let stepIndex = 0;
|
|
127
|
+
// Helper to add a step
|
|
128
|
+
const addStep = (step, isPartOfRepeat = false) => {
|
|
129
|
+
const durationType = getDurationType(step.duration.unit);
|
|
130
|
+
const durationValue = getDurationValue(step.duration.value, step.duration.unit);
|
|
131
|
+
const fitStep = {
|
|
132
|
+
messageIndex: stepIndex,
|
|
133
|
+
workoutStepName: step.name || "",
|
|
134
|
+
intensity: getStepIntensity(step.type),
|
|
135
|
+
durationType: durationType,
|
|
136
|
+
durationValue: durationValue,
|
|
137
|
+
notes: step.notes || "",
|
|
138
|
+
};
|
|
139
|
+
// Add target based on intensity unit
|
|
140
|
+
if (step.intensity) {
|
|
141
|
+
switch (step.intensity.unit) {
|
|
142
|
+
case "percent_ftp":
|
|
143
|
+
fitStep.targetType = "power";
|
|
144
|
+
fitStep.targetValue = 0;
|
|
145
|
+
fitStep.customTargetValueLow = step.intensity.valueLow || step.intensity.value - 5;
|
|
146
|
+
fitStep.customTargetValueHigh = step.intensity.valueHigh || step.intensity.value + 5;
|
|
147
|
+
break;
|
|
148
|
+
case "percent_lthr":
|
|
149
|
+
case "hr_zone":
|
|
150
|
+
fitStep.targetType = "heart_rate";
|
|
151
|
+
fitStep.targetValue = 0;
|
|
152
|
+
// HR zone values need to be actual BPM if available
|
|
153
|
+
if (step.intensity.valueLow !== undefined && step.intensity.valueHigh !== undefined) {
|
|
154
|
+
fitStep.customTargetValueLow = step.intensity.valueLow;
|
|
155
|
+
fitStep.customTargetValueHigh = step.intensity.valueHigh;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// Use zone as target value (1-5)
|
|
159
|
+
fitStep.targetValue = step.intensity.value;
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case "rpe":
|
|
163
|
+
// No direct RPE support in FIT, use open target
|
|
164
|
+
fitStep.targetType = "open";
|
|
165
|
+
break;
|
|
166
|
+
default:
|
|
167
|
+
fitStep.targetType = "open";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
fitStep.targetType = "open";
|
|
172
|
+
}
|
|
173
|
+
// Add cadence target if present
|
|
174
|
+
if (step.cadence) {
|
|
175
|
+
fitStep.customTargetCadenceLow = step.cadence.low;
|
|
176
|
+
fitStep.customTargetCadenceHigh = step.cadence.high;
|
|
177
|
+
}
|
|
178
|
+
steps.push(fitStep);
|
|
179
|
+
stepIndex++;
|
|
180
|
+
return stepIndex - 1;
|
|
181
|
+
};
|
|
182
|
+
// Helper to add interval set
|
|
183
|
+
const addIntervalSet = (intervalSet) => {
|
|
184
|
+
// For FIT, we need to add a repeat step that references the child steps
|
|
185
|
+
const repeatStepIndex = stepIndex;
|
|
186
|
+
stepIndex++; // Reserve index for repeat step
|
|
187
|
+
// Add the child steps
|
|
188
|
+
const childStepIndices = [];
|
|
189
|
+
for (const childStep of intervalSet.steps) {
|
|
190
|
+
childStepIndices.push(addStep(childStep, true));
|
|
191
|
+
}
|
|
192
|
+
// Create the repeat step
|
|
193
|
+
const repeatStep = {
|
|
194
|
+
messageIndex: repeatStepIndex,
|
|
195
|
+
workoutStepName: intervalSet.name || "Intervals",
|
|
196
|
+
durationType: "repeat_until_steps_cmplt",
|
|
197
|
+
durationValue: intervalSet.repeats,
|
|
198
|
+
targetType: "open",
|
|
199
|
+
intensity: "interval",
|
|
200
|
+
};
|
|
201
|
+
// Insert repeat step at correct position
|
|
202
|
+
steps.splice(repeatStepIndex, 0, repeatStep);
|
|
203
|
+
stepIndex++; // Adjust for inserted repeat step
|
|
204
|
+
};
|
|
205
|
+
// Process warmup
|
|
206
|
+
if (structure.warmup) {
|
|
207
|
+
for (const step of structure.warmup) {
|
|
208
|
+
addStep(step);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Process main set
|
|
212
|
+
for (const item of structure.main) {
|
|
213
|
+
if ("repeats" in item) {
|
|
214
|
+
addIntervalSet(item);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
addStep(item);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Process cooldown
|
|
221
|
+
if (structure.cooldown) {
|
|
222
|
+
for (const step of structure.cooldown) {
|
|
223
|
+
addStep(step);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { steps, totalSteps: steps.length };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Generate simple workout steps when no structure is provided
|
|
230
|
+
*/
|
|
231
|
+
function generateSimpleSteps(workout) {
|
|
232
|
+
const steps = [];
|
|
233
|
+
const totalMinutes = workout.durationMinutes || 60;
|
|
234
|
+
// Warmup (10% of total, 5-15 min)
|
|
235
|
+
const warmupMinutes = Math.min(15, Math.max(5, Math.round(totalMinutes * 0.1)));
|
|
236
|
+
steps.push({
|
|
237
|
+
messageIndex: 0,
|
|
238
|
+
workoutStepName: "Warm Up",
|
|
239
|
+
intensity: "warmup",
|
|
240
|
+
durationType: "time",
|
|
241
|
+
durationValue: warmupMinutes * 60 * 1000,
|
|
242
|
+
targetType: "open",
|
|
243
|
+
});
|
|
244
|
+
// Main (80% of total)
|
|
245
|
+
const cooldownMinutes = Math.min(10, Math.max(5, Math.round(totalMinutes * 0.1)));
|
|
246
|
+
const mainMinutes = totalMinutes - warmupMinutes - cooldownMinutes;
|
|
247
|
+
let mainIntensity = "active";
|
|
248
|
+
if (workout.type === "recovery")
|
|
249
|
+
mainIntensity = "recovery";
|
|
250
|
+
else if (workout.type === "rest")
|
|
251
|
+
mainIntensity = "rest";
|
|
252
|
+
else if (workout.type === "intervals" || workout.type === "vo2max")
|
|
253
|
+
mainIntensity = "interval";
|
|
254
|
+
steps.push({
|
|
255
|
+
messageIndex: 1,
|
|
256
|
+
workoutStepName: "Main Set",
|
|
257
|
+
intensity: mainIntensity,
|
|
258
|
+
durationType: "time",
|
|
259
|
+
durationValue: mainMinutes * 60 * 1000,
|
|
260
|
+
targetType: "open",
|
|
261
|
+
notes: workout.description || "",
|
|
262
|
+
});
|
|
263
|
+
// Cooldown (10% of total, 5-10 min)
|
|
264
|
+
steps.push({
|
|
265
|
+
messageIndex: 2,
|
|
266
|
+
workoutStepName: "Cool Down",
|
|
267
|
+
intensity: "cooldown",
|
|
268
|
+
durationType: "time",
|
|
269
|
+
durationValue: cooldownMinutes * 60 * 1000,
|
|
270
|
+
targetType: "open",
|
|
271
|
+
});
|
|
272
|
+
return { steps, totalSteps: 3 };
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Generate a complete FIT workout file
|
|
276
|
+
*/
|
|
277
|
+
export async function generateFit(workout, _settings) {
|
|
278
|
+
if (!isFitSupported(workout.sport)) {
|
|
279
|
+
throw new Error(`FIT export not supported for ${workout.sport} workouts`);
|
|
280
|
+
}
|
|
281
|
+
const encoder = new Encoder();
|
|
282
|
+
// File ID message (required)
|
|
283
|
+
encoder.onMesg(Profile.MesgNum.FILE_ID, {
|
|
284
|
+
type: "workout",
|
|
285
|
+
manufacturer: "development",
|
|
286
|
+
product: 1,
|
|
287
|
+
serialNumber: Math.floor(Math.random() * 1000000),
|
|
288
|
+
timeCreated: new Date(),
|
|
289
|
+
});
|
|
290
|
+
// Generate steps
|
|
291
|
+
const { steps, totalSteps } = workout.structure
|
|
292
|
+
? generateStepsFromStructure(workout.structure)
|
|
293
|
+
: generateSimpleSteps(workout);
|
|
294
|
+
// Workout message
|
|
295
|
+
encoder.onMesg(Profile.MesgNum.WORKOUT, {
|
|
296
|
+
workoutName: workout.name,
|
|
297
|
+
sport: getFitSport(workout.sport),
|
|
298
|
+
subSport: getFitSubSport(workout.sport),
|
|
299
|
+
numValidSteps: totalSteps,
|
|
300
|
+
});
|
|
301
|
+
// Write workout steps
|
|
302
|
+
for (const step of steps) {
|
|
303
|
+
encoder.onMesg(Profile.MesgNum.WORKOUT_STEP, step);
|
|
304
|
+
}
|
|
305
|
+
// Finalize and return
|
|
306
|
+
return encoder.close();
|
|
307
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICS (iCalendar) Export
|
|
3
|
+
*
|
|
4
|
+
* Generates iCalendar files (RFC 5545) for importing training plans
|
|
5
|
+
* into Google Calendar, Apple Calendar, Outlook, etc.
|
|
6
|
+
*
|
|
7
|
+
* Each workout becomes an all-day event on its scheduled date.
|
|
8
|
+
*/
|
|
9
|
+
import type { TrainingPlan } from "../../../schema/training-plan.js";
|
|
10
|
+
/**
|
|
11
|
+
* Generate a complete iCalendar file for the training plan
|
|
12
|
+
*/
|
|
13
|
+
export declare function generateIcs(plan: TrainingPlan): string;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICS (iCalendar) Export
|
|
3
|
+
*
|
|
4
|
+
* Generates iCalendar files (RFC 5545) for importing training plans
|
|
5
|
+
* into Google Calendar, Apple Calendar, Outlook, etc.
|
|
6
|
+
*
|
|
7
|
+
* Each workout becomes an all-day event on its scheduled date.
|
|
8
|
+
*/
|
|
9
|
+
import { formatDuration, getSportIcon } from "../utils.js";
|
|
10
|
+
/**
|
|
11
|
+
* Format date as iCalendar DATE value (YYYYMMDD)
|
|
12
|
+
*/
|
|
13
|
+
function formatIcsDate(dateStr) {
|
|
14
|
+
return dateStr.replace(/-/g, "");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Escape special characters for iCalendar text fields
|
|
18
|
+
* According to RFC 5545, we need to escape: backslash, semicolon, comma, newline
|
|
19
|
+
*/
|
|
20
|
+
function escapeIcsText(text) {
|
|
21
|
+
return text
|
|
22
|
+
.replace(/\\/g, "\\\\") // Backslash
|
|
23
|
+
.replace(/;/g, "\\;") // Semicolon
|
|
24
|
+
.replace(/,/g, "\\,") // Comma
|
|
25
|
+
.replace(/\n/g, "\\n") // Newline
|
|
26
|
+
.replace(/\\n/g, "\\n"); // Handle already-escaped newlines from humanReadable
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Fold long lines according to RFC 5545 (max 75 octets per line)
|
|
30
|
+
*/
|
|
31
|
+
function foldLine(line) {
|
|
32
|
+
const maxLength = 75;
|
|
33
|
+
if (line.length <= maxLength) {
|
|
34
|
+
return line;
|
|
35
|
+
}
|
|
36
|
+
const result = [];
|
|
37
|
+
let remaining = line;
|
|
38
|
+
// First line can be full length
|
|
39
|
+
result.push(remaining.substring(0, maxLength));
|
|
40
|
+
remaining = remaining.substring(maxLength);
|
|
41
|
+
// Continuation lines start with space and have length 74 (75 - 1 for space)
|
|
42
|
+
while (remaining.length > 0) {
|
|
43
|
+
result.push(" " + remaining.substring(0, 74));
|
|
44
|
+
remaining = remaining.substring(74);
|
|
45
|
+
}
|
|
46
|
+
return result.join("\r\n");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Generate a unique identifier for an event
|
|
50
|
+
*/
|
|
51
|
+
function generateUid(workoutId, date) {
|
|
52
|
+
return `${workoutId}-${date}@claude-coach`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Generate a VEVENT for a workout
|
|
56
|
+
*/
|
|
57
|
+
function generateVevent(workout, day, planName) {
|
|
58
|
+
const uid = generateUid(workout.id, day.date);
|
|
59
|
+
const dateStr = formatIcsDate(day.date);
|
|
60
|
+
const sportEmoji = getSportIcon(workout.sport);
|
|
61
|
+
const duration = formatDuration(workout.durationMinutes);
|
|
62
|
+
// Summary: emoji + sport type + workout name
|
|
63
|
+
const summary = escapeIcsText(`${sportEmoji} ${workout.sport.charAt(0).toUpperCase() + workout.sport.slice(1)}: ${workout.name}`);
|
|
64
|
+
// Description: include all workout details
|
|
65
|
+
let description = "";
|
|
66
|
+
if (workout.description) {
|
|
67
|
+
description += workout.description + "\\n\\n";
|
|
68
|
+
}
|
|
69
|
+
if (duration) {
|
|
70
|
+
description += `Duration: ${duration}\\n`;
|
|
71
|
+
}
|
|
72
|
+
if (workout.primaryZone) {
|
|
73
|
+
description += `Target Zone: ${workout.primaryZone}\\n`;
|
|
74
|
+
}
|
|
75
|
+
if (workout.humanReadable) {
|
|
76
|
+
description += "\\nWorkout Structure:\\n" + workout.humanReadable.replace(/\\n/g, "\\n");
|
|
77
|
+
}
|
|
78
|
+
description = escapeIcsText(description.trim());
|
|
79
|
+
// Categories for filtering
|
|
80
|
+
const categories = [workout.sport, workout.type, planName].join(",");
|
|
81
|
+
const lines = [
|
|
82
|
+
"BEGIN:VEVENT",
|
|
83
|
+
foldLine(`UID:${uid}`),
|
|
84
|
+
`DTSTAMP:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
|
|
85
|
+
`DTSTART;VALUE=DATE:${dateStr}`,
|
|
86
|
+
`DTEND;VALUE=DATE:${dateStr}`, // All-day event
|
|
87
|
+
foldLine(`SUMMARY:${summary}`),
|
|
88
|
+
foldLine(`DESCRIPTION:${description}`),
|
|
89
|
+
foldLine(`CATEGORIES:${escapeIcsText(categories)}`),
|
|
90
|
+
`TRANSP:TRANSPARENT`, // Don't block time (shows as free)
|
|
91
|
+
"END:VEVENT",
|
|
92
|
+
];
|
|
93
|
+
return lines.join("\r\n");
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Generate a complete iCalendar file for the training plan
|
|
97
|
+
*/
|
|
98
|
+
export function generateIcs(plan) {
|
|
99
|
+
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
|
100
|
+
const header = [
|
|
101
|
+
"BEGIN:VCALENDAR",
|
|
102
|
+
"VERSION:2.0",
|
|
103
|
+
"PRODID:-//Claude Coach//Training Plan//EN",
|
|
104
|
+
"CALSCALE:GREGORIAN",
|
|
105
|
+
"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
|
+
].join("\r\n");
|
|
109
|
+
const events = [];
|
|
110
|
+
// 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) {
|
|
114
|
+
// Skip rest days without actual workouts
|
|
115
|
+
if (workout.sport === "rest" && !workout.name) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
events.push(generateVevent(workout, day, plan.meta.event));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
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);
|
|
136
|
+
const footer = "END:VCALENDAR";
|
|
137
|
+
return header + "\r\n" + events.join("\r\n") + "\r\n" + footer;
|
|
138
|
+
}
|