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,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>;
|