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.
Files changed (43) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +84 -0
  3. package/bin/claude-coach.js +10 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +277 -0
  6. package/dist/db/client.d.ts +4 -0
  7. package/dist/db/client.js +45 -0
  8. package/dist/db/migrate.d.ts +1 -0
  9. package/dist/db/migrate.js +14 -0
  10. package/dist/lib/config.d.ts +27 -0
  11. package/dist/lib/config.js +86 -0
  12. package/dist/lib/logging.d.ts +13 -0
  13. package/dist/lib/logging.js +28 -0
  14. package/dist/schema/training-plan.d.ts +288 -0
  15. package/dist/schema/training-plan.js +88 -0
  16. package/dist/strava/api.d.ts +5 -0
  17. package/dist/strava/api.js +63 -0
  18. package/dist/strava/oauth.d.ts +4 -0
  19. package/dist/strava/oauth.js +113 -0
  20. package/dist/strava/types.d.ts +46 -0
  21. package/dist/strava/types.js +1 -0
  22. package/dist/viewer/lib/export/erg.d.ts +26 -0
  23. package/dist/viewer/lib/export/erg.js +206 -0
  24. package/dist/viewer/lib/export/fit.d.ts +25 -0
  25. package/dist/viewer/lib/export/fit.js +307 -0
  26. package/dist/viewer/lib/export/ics.d.ts +13 -0
  27. package/dist/viewer/lib/export/ics.js +138 -0
  28. package/dist/viewer/lib/export/index.d.ts +50 -0
  29. package/dist/viewer/lib/export/index.js +229 -0
  30. package/dist/viewer/lib/export/zwo.d.ts +21 -0
  31. package/dist/viewer/lib/export/zwo.js +230 -0
  32. package/dist/viewer/lib/utils.d.ts +14 -0
  33. package/dist/viewer/lib/utils.js +118 -0
  34. package/dist/viewer/main.d.ts +5 -0
  35. package/dist/viewer/main.js +6 -0
  36. package/dist/viewer/stores/changes.d.ts +21 -0
  37. package/dist/viewer/stores/changes.js +49 -0
  38. package/dist/viewer/stores/plan.d.ts +4 -0
  39. package/dist/viewer/stores/plan.js +19 -0
  40. package/dist/viewer/stores/settings.d.ts +53 -0
  41. package/dist/viewer/stores/settings.js +207 -0
  42. package/package.json +55 -0
  43. 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
+ }