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,48 @@
1
+ /**
2
+ * Client-side Plan Update & Regeneration
3
+ *
4
+ * This module applies local changes (from localStorage) to the plan JSON
5
+ * and generates downloadable updated files (JSON + HTML).
6
+ *
7
+ * This is the browser equivalent of the CLI `modify` + `render` commands.
8
+ */
9
+ import type { TrainingPlan, Workout } from "../../schema/training-plan.js";
10
+ export interface PlanChanges {
11
+ moved: Record<string, string>;
12
+ edited: Record<string, Partial<Workout>>;
13
+ deleted: string[];
14
+ added: Record<string, {
15
+ date: string;
16
+ workout: Workout;
17
+ }>;
18
+ }
19
+ export interface UpdateResult {
20
+ success: boolean;
21
+ planFilename?: string;
22
+ changesSummary?: {
23
+ deleted: number;
24
+ edited: number;
25
+ moved: number;
26
+ added: number;
27
+ completed: number;
28
+ };
29
+ error?: string;
30
+ }
31
+ /**
32
+ * Main function: Update plan and generate downloadable JSON
33
+ *
34
+ * This applies changes from localStorage and downloads the updated plan JSON.
35
+ * To get the HTML, use: endurance-coach render updated.json -o updated.html
36
+ */
37
+ export declare function updatePlanAndRegenerate(plan: TrainingPlan, changes: PlanChanges, completed: Record<string, boolean>): UpdateResult;
38
+ /**
39
+ * Get changes from localStorage
40
+ */
41
+ export declare function getChangesFromLocalStorage(planId: string): {
42
+ changes: PlanChanges;
43
+ completed: Record<string, boolean>;
44
+ };
45
+ /**
46
+ * Check if there are any pending changes
47
+ */
48
+ export declare function hasPendingChanges(planId: string): boolean;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Client-side Plan Update & Regeneration
3
+ *
4
+ * This module applies local changes (from localStorage) to the plan JSON
5
+ * and generates downloadable updated files (JSON + HTML).
6
+ *
7
+ * This is the browser equivalent of the CLI `modify` + `render` commands.
8
+ */
9
+ /**
10
+ * Apply all local changes to the plan (browser version of modify.ts)
11
+ */
12
+ function applyLocalChangesToPlan(plan, changes, completed) {
13
+ // Deep clone to avoid mutating original
14
+ const modifiedPlan = JSON.parse(JSON.stringify(plan));
15
+ // Track all workouts by ID for easy lookup
16
+ const workoutMap = new Map();
17
+ modifiedPlan.weeks?.forEach((week, weekIdx) => {
18
+ week.days?.forEach((day, dayIdx) => {
19
+ day.workouts?.forEach((workout, workoutIdx) => {
20
+ workoutMap.set(workout.id, { weekIdx, dayIdx, workoutIdx });
21
+ });
22
+ });
23
+ });
24
+ // 1. Apply deleted workouts
25
+ changes.deleted.forEach((workoutId) => {
26
+ const location = workoutMap.get(workoutId);
27
+ if (location) {
28
+ const { weekIdx, dayIdx, workoutIdx } = location;
29
+ modifiedPlan.weeks[weekIdx].days[dayIdx].workouts.splice(workoutIdx, 1);
30
+ }
31
+ });
32
+ // Rebuild workout map after deletions
33
+ workoutMap.clear();
34
+ modifiedPlan.weeks?.forEach((week, weekIdx) => {
35
+ week.days?.forEach((day, dayIdx) => {
36
+ day.workouts?.forEach((workout, workoutIdx) => {
37
+ workoutMap.set(workout.id, { weekIdx, dayIdx, workoutIdx });
38
+ });
39
+ });
40
+ });
41
+ // 2. Apply edits to existing workouts
42
+ Object.entries(changes.edited).forEach(([workoutId, edits]) => {
43
+ const location = workoutMap.get(workoutId);
44
+ if (location) {
45
+ const { weekIdx, dayIdx, workoutIdx } = location;
46
+ const workout = modifiedPlan.weeks[weekIdx].days[dayIdx].workouts[workoutIdx];
47
+ Object.assign(workout, edits);
48
+ }
49
+ });
50
+ // 3. Apply moved workouts
51
+ Object.entries(changes.moved).forEach(([workoutId, newDate]) => {
52
+ const location = workoutMap.get(workoutId);
53
+ if (!location)
54
+ return;
55
+ const { weekIdx, dayIdx, workoutIdx } = location;
56
+ // Remove workout from original location
57
+ const [workout] = modifiedPlan.weeks[weekIdx].days[dayIdx].workouts.splice(workoutIdx, 1);
58
+ // Find the target day
59
+ let targetDay = null;
60
+ for (const week of modifiedPlan.weeks || []) {
61
+ for (const day of week.days || []) {
62
+ if (day.date === newDate) {
63
+ targetDay = day;
64
+ break;
65
+ }
66
+ }
67
+ if (targetDay)
68
+ break;
69
+ }
70
+ if (targetDay) {
71
+ if (!targetDay.workouts) {
72
+ targetDay.workouts = [];
73
+ }
74
+ targetDay.workouts.push(workout);
75
+ }
76
+ });
77
+ // 4. Add new workouts
78
+ Object.entries(changes.added).forEach(([workoutId, { date, workout }]) => {
79
+ let targetDay = null;
80
+ for (const week of modifiedPlan.weeks || []) {
81
+ for (const day of week.days || []) {
82
+ if (day.date === date) {
83
+ targetDay = day;
84
+ break;
85
+ }
86
+ }
87
+ if (targetDay)
88
+ break;
89
+ }
90
+ if (targetDay) {
91
+ if (!targetDay.workouts) {
92
+ targetDay.workouts = [];
93
+ }
94
+ targetDay.workouts.push(workout);
95
+ }
96
+ });
97
+ // 5. Apply completed status
98
+ modifiedPlan.weeks?.forEach((week) => {
99
+ week.days?.forEach((day) => {
100
+ day.workouts?.forEach((workout) => {
101
+ workout.completed = completed[workout.id] || false;
102
+ });
103
+ });
104
+ });
105
+ // Update metadata
106
+ modifiedPlan.meta.updatedAt = new Date().toISOString();
107
+ return modifiedPlan;
108
+ }
109
+ /**
110
+ * Download a file to the user's computer
111
+ */
112
+ function downloadFile(content, filename, mimeType) {
113
+ const blob = new Blob([content], { type: mimeType });
114
+ const url = URL.createObjectURL(blob);
115
+ const link = document.createElement("a");
116
+ link.href = url;
117
+ link.download = filename;
118
+ document.body.appendChild(link);
119
+ link.click();
120
+ document.body.removeChild(link);
121
+ URL.revokeObjectURL(url);
122
+ }
123
+ /**
124
+ * Generate a safe filename from the plan
125
+ */
126
+ function generateFilename(plan, extension) {
127
+ const safeName = plan.meta.event
128
+ .replace(/[<>:"/\\|?*]/g, "")
129
+ .replace(/\s+/g, "-")
130
+ .toLowerCase()
131
+ .substring(0, 50);
132
+ const date = new Date().toISOString().split("T")[0];
133
+ return `${safeName}-updated-${date}.${extension}`;
134
+ }
135
+ /**
136
+ * Count total changes
137
+ */
138
+ function countChanges(changes, completed) {
139
+ return (changes.deleted.length +
140
+ Object.keys(changes.edited).length +
141
+ Object.keys(changes.moved).length +
142
+ Object.keys(changes.added).length +
143
+ Object.keys(completed).filter((id) => completed[id]).length);
144
+ }
145
+ /**
146
+ * Main function: Update plan and generate downloadable JSON
147
+ *
148
+ * This applies changes from localStorage and downloads the updated plan JSON.
149
+ * To get the HTML, use: endurance-coach render updated.json -o updated.html
150
+ */
151
+ export function updatePlanAndRegenerate(plan, changes, completed) {
152
+ try {
153
+ // Check if there are any changes to apply
154
+ const totalChanges = countChanges(changes, completed);
155
+ if (totalChanges === 0) {
156
+ return {
157
+ success: false,
158
+ error: "No changes to apply",
159
+ };
160
+ }
161
+ // 1. Apply all changes to the plan
162
+ const updatedPlan = applyLocalChangesToPlan(plan, changes, completed);
163
+ // 2. Generate and download new plan JSON
164
+ const planJson = JSON.stringify(updatedPlan, null, 2);
165
+ const planFilename = generateFilename(updatedPlan, "json");
166
+ downloadFile(planJson, planFilename, "application/json");
167
+ // 3. Calculate summary
168
+ const completedCount = Object.keys(completed).filter((id) => completed[id]).length;
169
+ return {
170
+ success: true,
171
+ planFilename,
172
+ changesSummary: {
173
+ deleted: changes.deleted.length,
174
+ edited: Object.keys(changes.edited).length,
175
+ moved: Object.keys(changes.moved).length,
176
+ added: Object.keys(changes.added).length,
177
+ completed: completedCount,
178
+ },
179
+ };
180
+ }
181
+ catch (error) {
182
+ console.error("Error updating plan:", error);
183
+ return {
184
+ success: false,
185
+ error: error instanceof Error ? error.message : "Unknown error",
186
+ };
187
+ }
188
+ }
189
+ /**
190
+ * Get changes from localStorage
191
+ */
192
+ export function getChangesFromLocalStorage(planId) {
193
+ const changesKey = `plan-${planId}-changes`;
194
+ const completedKey = `plan-${planId}-completed`;
195
+ const changesJson = localStorage.getItem(changesKey);
196
+ const completedJson = localStorage.getItem(completedKey);
197
+ const changes = changesJson
198
+ ? JSON.parse(changesJson)
199
+ : { moved: {}, edited: {}, deleted: [], added: {} };
200
+ const completed = completedJson ? JSON.parse(completedJson) : {};
201
+ return { changes, completed };
202
+ }
203
+ /**
204
+ * Check if there are any pending changes
205
+ */
206
+ export function hasPendingChanges(planId) {
207
+ const { changes, completed } = getChangesFromLocalStorage(planId);
208
+ return countChanges(changes, completed) > 0;
209
+ }
@@ -0,0 +1,26 @@
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
+ import type { Workout, Sport } from "../../../schema/training-plan.js";
13
+ import type { Settings } from "../../stores/settings.js";
14
+ /**
15
+ * Check if a sport is supported by ERG/MRC export
16
+ * Only cycling workouts make sense for trainer files
17
+ */
18
+ export declare function isErgSupported(sport: Sport): boolean;
19
+ /**
20
+ * Generate a complete MRC file for a workout
21
+ */
22
+ export declare function generateMrc(workout: Workout, _settings: Settings): string;
23
+ /**
24
+ * Generate ERG file (absolute watts) - requires FTP
25
+ */
26
+ export declare function generateErg(workout: Workout, settings: Settings): string;
@@ -0,0 +1,208 @@
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 ?? 50);
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
+ const unit = step.duration?.unit ?? "minutes";
54
+ const value = step.duration?.value ?? 0;
55
+ switch (unit) {
56
+ case "seconds":
57
+ return value / 60;
58
+ case "minutes":
59
+ return value;
60
+ case "hours":
61
+ return value * 60;
62
+ default:
63
+ // For distance-based, estimate ~30km/h average
64
+ if (unit === "meters") {
65
+ return (value / 1000 / 30) * 60;
66
+ }
67
+ if (unit === "kilometers") {
68
+ return (value / 30) * 60;
69
+ }
70
+ return value;
71
+ }
72
+ };
73
+ // Process warmup
74
+ if (structure.warmup) {
75
+ for (const step of structure.warmup) {
76
+ addStep(step);
77
+ }
78
+ }
79
+ // Process main set
80
+ for (const item of structure.main ?? []) {
81
+ if ("repeats" in item) {
82
+ const intervalSet = item;
83
+ for (let i = 0; i < (intervalSet.repeats ?? 1); i++) {
84
+ for (const step of intervalSet.steps ?? []) {
85
+ addStep(step);
86
+ }
87
+ }
88
+ }
89
+ else {
90
+ addStep(item);
91
+ }
92
+ }
93
+ // Process cooldown
94
+ if (structure.cooldown) {
95
+ for (const step of structure.cooldown) {
96
+ addStep(step);
97
+ }
98
+ }
99
+ return points;
100
+ }
101
+ /**
102
+ * Generate simple data points when no structure is provided
103
+ */
104
+ function generateSimpleDataPoints(workout) {
105
+ const totalMinutes = workout.durationMinutes || 60;
106
+ const points = [];
107
+ // Determine main intensity based on workout type
108
+ let mainPercent = 65;
109
+ switch (workout.type) {
110
+ case "recovery":
111
+ mainPercent = 55;
112
+ break;
113
+ case "endurance":
114
+ mainPercent = 65;
115
+ break;
116
+ case "tempo":
117
+ mainPercent = 80;
118
+ break;
119
+ case "threshold":
120
+ mainPercent = 95;
121
+ break;
122
+ case "vo2max":
123
+ mainPercent = 110;
124
+ break;
125
+ case "intervals":
126
+ mainPercent = 85;
127
+ break;
128
+ }
129
+ // Warmup (10% of total, ramp from 40% to 65%)
130
+ const warmupMinutes = Math.min(15, Math.max(5, Math.round(totalMinutes * 0.1)));
131
+ points.push([0, 40]);
132
+ points.push([warmupMinutes, 65]);
133
+ // Main set
134
+ const cooldownMinutes = Math.min(10, Math.max(5, Math.round(totalMinutes * 0.1)));
135
+ const mainMinutes = totalMinutes - warmupMinutes - cooldownMinutes;
136
+ points.push([warmupMinutes, mainPercent]);
137
+ points.push([warmupMinutes + mainMinutes, mainPercent]);
138
+ // Cooldown (ramp from 60% to 40%)
139
+ points.push([warmupMinutes + mainMinutes, 60]);
140
+ points.push([totalMinutes, 40]);
141
+ return points;
142
+ }
143
+ /**
144
+ * Generate a complete MRC file for a workout
145
+ */
146
+ export function generateMrc(workout, _settings) {
147
+ if (!isErgSupported(workout.sport)) {
148
+ throw new Error(`ERG/MRC export only supports bike workouts`);
149
+ }
150
+ const dataPoints = workout.structure
151
+ ? generateDataPoints(workout.structure)
152
+ : generateSimpleDataPoints(workout);
153
+ // Build description from workout info
154
+ let description = workout.name;
155
+ if (workout.description) {
156
+ description += ` - ${workout.description}`;
157
+ }
158
+ const lines = [
159
+ "[COURSE HEADER]",
160
+ `VERSION = 2`,
161
+ `UNITS = ENGLISH`,
162
+ `DESCRIPTION = ${description.replace(/[\r\n]/g, " ")}`,
163
+ `FILE NAME = ${workout.name}`,
164
+ `MINUTES PERCENT`,
165
+ "[END COURSE HEADER]",
166
+ "[COURSE DATA]",
167
+ ];
168
+ // Add data points
169
+ for (const [minutes, percent] of dataPoints) {
170
+ lines.push(`${minutes.toFixed(2)}\t${percent.toFixed(0)}`);
171
+ }
172
+ lines.push("[END COURSE DATA]");
173
+ return lines.join("\n");
174
+ }
175
+ /**
176
+ * Generate ERG file (absolute watts) - requires FTP
177
+ */
178
+ export function generateErg(workout, settings) {
179
+ if (!isErgSupported(workout.sport)) {
180
+ throw new Error(`ERG/MRC export only supports bike workouts`);
181
+ }
182
+ const ftp = settings.bike?.ftp || 200; // Default to 200W if not set
183
+ const dataPoints = workout.structure
184
+ ? generateDataPoints(workout.structure)
185
+ : generateSimpleDataPoints(workout);
186
+ let description = workout.name;
187
+ if (workout.description) {
188
+ description += ` - ${workout.description}`;
189
+ }
190
+ const lines = [
191
+ "[COURSE HEADER]",
192
+ `VERSION = 2`,
193
+ `UNITS = ENGLISH`,
194
+ `DESCRIPTION = ${description.replace(/[\r\n]/g, " ")}`,
195
+ `FILE NAME = ${workout.name}`,
196
+ `FTP = ${ftp}`,
197
+ `MINUTES WATTS`,
198
+ "[END COURSE HEADER]",
199
+ "[COURSE DATA]",
200
+ ];
201
+ // Add data points converted to watts
202
+ for (const [minutes, percent] of dataPoints) {
203
+ const watts = Math.round((percent / 100) * ftp);
204
+ lines.push(`${minutes.toFixed(2)}\t${watts}`);
205
+ }
206
+ lines.push("[END COURSE DATA]");
207
+ return lines.join("\n");
208
+ }
@@ -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>;