endurance-coach 0.1.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.
- package/LICENSE.md +21 -0
- package/README.md +94 -0
- package/bin/claude-coach.js +10 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +1077 -0
- package/dist/db/client.d.ts +8 -0
- package/dist/db/client.js +111 -0
- package/dist/db/migrate.d.ts +1 -0
- package/dist/db/migrate.js +14 -0
- package/dist/db/schema.sql +105 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +13 -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/schema/training-plan.schema.d.ts +1875 -0
- package/dist/schema/training-plan.schema.js +418 -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/UpdatePlan.d.ts +48 -0
- package/dist/viewer/lib/UpdatePlan.js +209 -0
- package/dist/viewer/lib/export/erg.d.ts +26 -0
- package/dist/viewer/lib/export/erg.js +208 -0
- package/dist/viewer/lib/export/fit.d.ts +25 -0
- package/dist/viewer/lib/export/fit.js +308 -0
- package/dist/viewer/lib/export/ics.d.ts +13 -0
- package/dist/viewer/lib/export/ics.js +142 -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 +233 -0
- package/dist/viewer/lib/utils.d.ts +14 -0
- package/dist/viewer/lib/utils.js +123 -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 +11 -0
- package/dist/viewer/stores/plan.js +40 -0
- package/dist/viewer/stores/settings.d.ts +53 -0
- package/dist/viewer/stores/settings.js +215 -0
- package/package.json +74 -0
- package/templates/plan-viewer.html +70 -0
|
@@ -0,0 +1,308 @@
|
|
|
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 ?? "minutes");
|
|
130
|
+
const durationValue = getDurationValue(step.duration?.value ?? 0, step.duration?.unit ?? "minutes");
|
|
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
|
+
const intensityValue = step.intensity.value ?? 50;
|
|
142
|
+
switch (step.intensity.unit) {
|
|
143
|
+
case "percent_ftp":
|
|
144
|
+
fitStep.targetType = "power";
|
|
145
|
+
fitStep.targetValue = 0;
|
|
146
|
+
fitStep.customTargetValueLow = step.intensity.valueLow ?? intensityValue - 5;
|
|
147
|
+
fitStep.customTargetValueHigh = step.intensity.valueHigh ?? intensityValue + 5;
|
|
148
|
+
break;
|
|
149
|
+
case "percent_lthr":
|
|
150
|
+
case "hr_zone":
|
|
151
|
+
fitStep.targetType = "heart_rate";
|
|
152
|
+
fitStep.targetValue = 0;
|
|
153
|
+
// HR zone values need to be actual BPM if available
|
|
154
|
+
if (step.intensity.valueLow !== undefined && step.intensity.valueHigh !== undefined) {
|
|
155
|
+
fitStep.customTargetValueLow = step.intensity.valueLow;
|
|
156
|
+
fitStep.customTargetValueHigh = step.intensity.valueHigh;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// Use zone as target value (1-5)
|
|
160
|
+
fitStep.targetValue = intensityValue;
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
case "rpe":
|
|
164
|
+
// No direct RPE support in FIT, use open target
|
|
165
|
+
fitStep.targetType = "open";
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
fitStep.targetType = "open";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
fitStep.targetType = "open";
|
|
173
|
+
}
|
|
174
|
+
// Add cadence target if present
|
|
175
|
+
if (step.cadence) {
|
|
176
|
+
fitStep.customTargetCadenceLow = step.cadence.low ?? 80;
|
|
177
|
+
fitStep.customTargetCadenceHigh = step.cadence.high ?? 100;
|
|
178
|
+
}
|
|
179
|
+
steps.push(fitStep);
|
|
180
|
+
stepIndex++;
|
|
181
|
+
return stepIndex - 1;
|
|
182
|
+
};
|
|
183
|
+
// Helper to add interval set
|
|
184
|
+
const addIntervalSet = (intervalSet) => {
|
|
185
|
+
// For FIT, we need to add a repeat step that references the child steps
|
|
186
|
+
const repeatStepIndex = stepIndex;
|
|
187
|
+
stepIndex++; // Reserve index for repeat step
|
|
188
|
+
// Add the child steps
|
|
189
|
+
const childStepIndices = [];
|
|
190
|
+
for (const childStep of intervalSet.steps) {
|
|
191
|
+
childStepIndices.push(addStep(childStep, true));
|
|
192
|
+
}
|
|
193
|
+
// Create the repeat step
|
|
194
|
+
const repeatStep = {
|
|
195
|
+
messageIndex: repeatStepIndex,
|
|
196
|
+
workoutStepName: intervalSet.name || "Intervals",
|
|
197
|
+
durationType: "repeat_until_steps_cmplt",
|
|
198
|
+
durationValue: intervalSet.repeats,
|
|
199
|
+
targetType: "open",
|
|
200
|
+
intensity: "interval",
|
|
201
|
+
};
|
|
202
|
+
// Insert repeat step at correct position
|
|
203
|
+
steps.splice(repeatStepIndex, 0, repeatStep);
|
|
204
|
+
stepIndex++; // Adjust for inserted repeat step
|
|
205
|
+
};
|
|
206
|
+
// Process warmup
|
|
207
|
+
if (structure.warmup) {
|
|
208
|
+
for (const step of structure.warmup) {
|
|
209
|
+
addStep(step);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Process main set
|
|
213
|
+
for (const item of structure.main) {
|
|
214
|
+
if ("repeats" in item) {
|
|
215
|
+
addIntervalSet(item);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
addStep(item);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Process cooldown
|
|
222
|
+
if (structure.cooldown) {
|
|
223
|
+
for (const step of structure.cooldown) {
|
|
224
|
+
addStep(step);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { steps, totalSteps: steps.length };
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Generate simple workout steps when no structure is provided
|
|
231
|
+
*/
|
|
232
|
+
function generateSimpleSteps(workout) {
|
|
233
|
+
const steps = [];
|
|
234
|
+
const totalMinutes = workout.durationMinutes || 60;
|
|
235
|
+
// Warmup (10% of total, 5-15 min)
|
|
236
|
+
const warmupMinutes = Math.min(15, Math.max(5, Math.round(totalMinutes * 0.1)));
|
|
237
|
+
steps.push({
|
|
238
|
+
messageIndex: 0,
|
|
239
|
+
workoutStepName: "Warm Up",
|
|
240
|
+
intensity: "warmup",
|
|
241
|
+
durationType: "time",
|
|
242
|
+
durationValue: warmupMinutes * 60 * 1000,
|
|
243
|
+
targetType: "open",
|
|
244
|
+
});
|
|
245
|
+
// Main (80% of total)
|
|
246
|
+
const cooldownMinutes = Math.min(10, Math.max(5, Math.round(totalMinutes * 0.1)));
|
|
247
|
+
const mainMinutes = totalMinutes - warmupMinutes - cooldownMinutes;
|
|
248
|
+
let mainIntensity = "active";
|
|
249
|
+
if (workout.type === "recovery")
|
|
250
|
+
mainIntensity = "recovery";
|
|
251
|
+
else if (workout.type === "rest")
|
|
252
|
+
mainIntensity = "rest";
|
|
253
|
+
else if (workout.type === "intervals" || workout.type === "vo2max")
|
|
254
|
+
mainIntensity = "interval";
|
|
255
|
+
steps.push({
|
|
256
|
+
messageIndex: 1,
|
|
257
|
+
workoutStepName: "Main Set",
|
|
258
|
+
intensity: mainIntensity,
|
|
259
|
+
durationType: "time",
|
|
260
|
+
durationValue: mainMinutes * 60 * 1000,
|
|
261
|
+
targetType: "open",
|
|
262
|
+
notes: workout.description || "",
|
|
263
|
+
});
|
|
264
|
+
// Cooldown (10% of total, 5-10 min)
|
|
265
|
+
steps.push({
|
|
266
|
+
messageIndex: 2,
|
|
267
|
+
workoutStepName: "Cool Down",
|
|
268
|
+
intensity: "cooldown",
|
|
269
|
+
durationType: "time",
|
|
270
|
+
durationValue: cooldownMinutes * 60 * 1000,
|
|
271
|
+
targetType: "open",
|
|
272
|
+
});
|
|
273
|
+
return { steps, totalSteps: 3 };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Generate a complete FIT workout file
|
|
277
|
+
*/
|
|
278
|
+
export async function generateFit(workout, _settings) {
|
|
279
|
+
if (!isFitSupported(workout.sport)) {
|
|
280
|
+
throw new Error(`FIT export not supported for ${workout.sport} workouts`);
|
|
281
|
+
}
|
|
282
|
+
const encoder = new Encoder();
|
|
283
|
+
// File ID message (required)
|
|
284
|
+
encoder.onMesg(Profile.MesgNum.FILE_ID, {
|
|
285
|
+
type: "workout",
|
|
286
|
+
manufacturer: "development",
|
|
287
|
+
product: 1,
|
|
288
|
+
serialNumber: Math.floor(Math.random() * 1000000),
|
|
289
|
+
timeCreated: new Date(),
|
|
290
|
+
});
|
|
291
|
+
// Generate steps
|
|
292
|
+
const { steps, totalSteps } = workout.structure
|
|
293
|
+
? generateStepsFromStructure(workout.structure)
|
|
294
|
+
: generateSimpleSteps(workout);
|
|
295
|
+
// Workout message
|
|
296
|
+
encoder.onMesg(Profile.MesgNum.WORKOUT, {
|
|
297
|
+
workoutName: workout.name,
|
|
298
|
+
sport: getFitSport(workout.sport),
|
|
299
|
+
subSport: getFitSubSport(workout.sport),
|
|
300
|
+
numValidSteps: totalSteps,
|
|
301
|
+
});
|
|
302
|
+
// Write workout steps
|
|
303
|
+
for (const step of steps) {
|
|
304
|
+
encoder.onMesg(Profile.MesgNum.WORKOUT_STEP, step);
|
|
305
|
+
}
|
|
306
|
+
// Finalize and return
|
|
307
|
+
return encoder.close();
|
|
308
|
+
}
|
|
@@ -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,142 @@
|
|
|
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}@endurance-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 eventName = plan.meta?.event ?? "Training Plan";
|
|
101
|
+
const eventDate = plan.meta?.eventDate ?? "";
|
|
102
|
+
const header = [
|
|
103
|
+
"BEGIN:VCALENDAR",
|
|
104
|
+
"VERSION:2.0",
|
|
105
|
+
"PRODID:-//Endurance Coach//Training Plan//EN",
|
|
106
|
+
"CALSCALE:GREGORIAN",
|
|
107
|
+
"METHOD:PUBLISH",
|
|
108
|
+
`X-WR-CALNAME:${escapeIcsText(eventName)} Training`,
|
|
109
|
+
`X-WR-CALDESC:${escapeIcsText(`Training plan for ${eventName} on ${eventDate}`)}`,
|
|
110
|
+
].join("\r\n");
|
|
111
|
+
const events = [];
|
|
112
|
+
// Generate events for all workouts
|
|
113
|
+
for (const week of plan.weeks ?? []) {
|
|
114
|
+
for (const day of week.days ?? []) {
|
|
115
|
+
for (const workout of day.workouts ?? []) {
|
|
116
|
+
// Skip rest days without actual workouts
|
|
117
|
+
if (workout.sport === "rest" && !workout.name) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
events.push(generateVevent(workout, day, eventName));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Add a race day event if we have a date
|
|
125
|
+
if (eventDate) {
|
|
126
|
+
const raceDayEvent = [
|
|
127
|
+
"BEGIN:VEVENT",
|
|
128
|
+
`UID:race-day@endurance-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
|
+
}
|
|
140
|
+
const footer = "END:VCALENDAR";
|
|
141
|
+
return header + "\r\n" + events.join("\r\n") + "\r\n" + footer;
|
|
142
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Training Plan Export
|
|
3
|
+
*
|
|
4
|
+
* Unified export interface for generating workout files client-side.
|
|
5
|
+
* Supports:
|
|
6
|
+
* - ZWO (Zwift workouts) - bike/run only
|
|
7
|
+
* - FIT (Garmin workouts) - all sports
|
|
8
|
+
* - MRC (ERG/MRC) - bike only, for indoor trainers
|
|
9
|
+
* - ICS (iCalendar) - full plan calendar events
|
|
10
|
+
*/
|
|
11
|
+
import type { Workout, TrainingPlan, Sport } from "../../../schema/training-plan.js";
|
|
12
|
+
import type { Settings } from "../../stores/settings.js";
|
|
13
|
+
export type ExportFormat = "zwo" | "fit" | "mrc" | "ics";
|
|
14
|
+
export interface ExportResult {
|
|
15
|
+
success: boolean;
|
|
16
|
+
filename: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Trigger a file download in the browser
|
|
21
|
+
*/
|
|
22
|
+
export declare function downloadFile(content: string | Uint8Array, filename: string, mimeType: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* Sanitize filename to be safe across platforms
|
|
25
|
+
*/
|
|
26
|
+
export declare function sanitizeFilename(name: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Get available export formats for a workout based on its sport
|
|
29
|
+
*/
|
|
30
|
+
export declare function getAvailableFormats(sport: Sport): ExportFormat[];
|
|
31
|
+
/**
|
|
32
|
+
* Export a single workout to the specified format
|
|
33
|
+
*/
|
|
34
|
+
export declare function exportWorkout(workout: Workout, format: ExportFormat, settings: Settings): Promise<ExportResult>;
|
|
35
|
+
/**
|
|
36
|
+
* Export the full training plan to iCalendar format
|
|
37
|
+
*/
|
|
38
|
+
export declare function exportPlanToCalendar(plan: TrainingPlan): ExportResult;
|
|
39
|
+
/**
|
|
40
|
+
* Export all workouts in the plan to a single ZIP file
|
|
41
|
+
* Contains ZWO/FIT/MRC files based on the selected format
|
|
42
|
+
*/
|
|
43
|
+
export declare function exportAllWorkouts(plan: TrainingPlan, format: "zwo" | "fit" | "mrc", settings: Settings): Promise<{
|
|
44
|
+
exported: number;
|
|
45
|
+
skipped: number;
|
|
46
|
+
errors: string[];
|
|
47
|
+
}>;
|
|
48
|
+
export { isZwoSupported } from "./zwo.js";
|
|
49
|
+
export { isFitSupported, isFitSdkAvailable } from "./fit.js";
|
|
50
|
+
export { isErgSupported } from "./erg.js";
|