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.
Files changed (50) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +94 -0
  3. package/bin/claude-coach.js +10 -0
  4. package/dist/cli.d.ts +6 -0
  5. package/dist/cli.js +1077 -0
  6. package/dist/db/client.d.ts +8 -0
  7. package/dist/db/client.js +111 -0
  8. package/dist/db/migrate.d.ts +1 -0
  9. package/dist/db/migrate.js +14 -0
  10. package/dist/db/schema.sql +105 -0
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.js +13 -0
  13. package/dist/lib/config.d.ts +27 -0
  14. package/dist/lib/config.js +86 -0
  15. package/dist/lib/logging.d.ts +13 -0
  16. package/dist/lib/logging.js +28 -0
  17. package/dist/schema/training-plan.d.ts +288 -0
  18. package/dist/schema/training-plan.js +88 -0
  19. package/dist/schema/training-plan.schema.d.ts +1875 -0
  20. package/dist/schema/training-plan.schema.js +418 -0
  21. package/dist/strava/api.d.ts +5 -0
  22. package/dist/strava/api.js +63 -0
  23. package/dist/strava/oauth.d.ts +4 -0
  24. package/dist/strava/oauth.js +113 -0
  25. package/dist/strava/types.d.ts +46 -0
  26. package/dist/strava/types.js +1 -0
  27. package/dist/viewer/lib/UpdatePlan.d.ts +48 -0
  28. package/dist/viewer/lib/UpdatePlan.js +209 -0
  29. package/dist/viewer/lib/export/erg.d.ts +26 -0
  30. package/dist/viewer/lib/export/erg.js +208 -0
  31. package/dist/viewer/lib/export/fit.d.ts +25 -0
  32. package/dist/viewer/lib/export/fit.js +308 -0
  33. package/dist/viewer/lib/export/ics.d.ts +13 -0
  34. package/dist/viewer/lib/export/ics.js +142 -0
  35. package/dist/viewer/lib/export/index.d.ts +50 -0
  36. package/dist/viewer/lib/export/index.js +229 -0
  37. package/dist/viewer/lib/export/zwo.d.ts +21 -0
  38. package/dist/viewer/lib/export/zwo.js +233 -0
  39. package/dist/viewer/lib/utils.d.ts +14 -0
  40. package/dist/viewer/lib/utils.js +123 -0
  41. package/dist/viewer/main.d.ts +5 -0
  42. package/dist/viewer/main.js +6 -0
  43. package/dist/viewer/stores/changes.d.ts +21 -0
  44. package/dist/viewer/stores/changes.js +49 -0
  45. package/dist/viewer/stores/plan.d.ts +11 -0
  46. package/dist/viewer/stores/plan.js +40 -0
  47. package/dist/viewer/stores/settings.d.ts +53 -0
  48. package/dist/viewer/stores/settings.js +215 -0
  49. package/package.json +74 -0
  50. 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";