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.
- package/LICENSE.md +21 -0
- package/README.md +84 -0
- package/bin/claude-coach.js +10 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +277 -0
- package/dist/db/client.d.ts +4 -0
- package/dist/db/client.js +45 -0
- package/dist/db/migrate.d.ts +1 -0
- package/dist/db/migrate.js +14 -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/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/export/erg.d.ts +26 -0
- package/dist/viewer/lib/export/erg.js +206 -0
- package/dist/viewer/lib/export/fit.d.ts +25 -0
- package/dist/viewer/lib/export/fit.js +307 -0
- package/dist/viewer/lib/export/ics.d.ts +13 -0
- package/dist/viewer/lib/export/ics.js +138 -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 +230 -0
- package/dist/viewer/lib/utils.d.ts +14 -0
- package/dist/viewer/lib/utils.js +118 -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 +4 -0
- package/dist/viewer/stores/plan.js +19 -0
- package/dist/viewer/stores/settings.d.ts +53 -0
- package/dist/viewer/stores/settings.js +207 -0
- package/package.json +55 -0
- package/templates/plan-viewer.html +70 -0
|
@@ -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";
|
|
@@ -0,0 +1,229 @@
|
|
|
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 { generateZwo, isZwoSupported } from "./zwo.js";
|
|
12
|
+
import { generateFit, isFitSupported } from "./fit.js";
|
|
13
|
+
import { generateMrc, isErgSupported } from "./erg.js";
|
|
14
|
+
import { generateIcs } from "./ics.js";
|
|
15
|
+
import JSZip from "jszip";
|
|
16
|
+
/**
|
|
17
|
+
* Trigger a file download in the browser
|
|
18
|
+
*/
|
|
19
|
+
export function downloadFile(content, filename, mimeType) {
|
|
20
|
+
const blob = content instanceof Uint8Array
|
|
21
|
+
? new Blob([new Uint8Array(content)], { type: mimeType })
|
|
22
|
+
: new Blob([content], { type: mimeType });
|
|
23
|
+
const url = URL.createObjectURL(blob);
|
|
24
|
+
const link = document.createElement("a");
|
|
25
|
+
link.href = url;
|
|
26
|
+
link.download = filename;
|
|
27
|
+
document.body.appendChild(link);
|
|
28
|
+
link.click();
|
|
29
|
+
document.body.removeChild(link);
|
|
30
|
+
URL.revokeObjectURL(url);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Sanitize filename to be safe across platforms
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeFilename(name) {
|
|
36
|
+
return name
|
|
37
|
+
.replace(/[<>:"/\\|?*]/g, "") // Remove invalid chars
|
|
38
|
+
.replace(/\s+/g, "_") // Replace spaces with underscores
|
|
39
|
+
.substring(0, 100); // Limit length
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get available export formats for a workout based on its sport
|
|
43
|
+
*/
|
|
44
|
+
export function getAvailableFormats(sport) {
|
|
45
|
+
const formats = [];
|
|
46
|
+
if (isZwoSupported(sport)) {
|
|
47
|
+
formats.push("zwo");
|
|
48
|
+
}
|
|
49
|
+
if (isFitSupported(sport)) {
|
|
50
|
+
formats.push("fit");
|
|
51
|
+
}
|
|
52
|
+
if (isErgSupported(sport)) {
|
|
53
|
+
formats.push("mrc");
|
|
54
|
+
}
|
|
55
|
+
return formats;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Export a single workout to the specified format
|
|
59
|
+
*/
|
|
60
|
+
export async function exportWorkout(workout, format, settings) {
|
|
61
|
+
const safeName = sanitizeFilename(workout.name);
|
|
62
|
+
try {
|
|
63
|
+
switch (format) {
|
|
64
|
+
case "zwo": {
|
|
65
|
+
if (!isZwoSupported(workout.sport)) {
|
|
66
|
+
return {
|
|
67
|
+
success: false,
|
|
68
|
+
filename: "",
|
|
69
|
+
error: `Zwift export only supports bike and run workouts`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const zwoContent = generateZwo(workout, settings);
|
|
73
|
+
const filename = `${safeName}.zwo`;
|
|
74
|
+
downloadFile(zwoContent, filename, "application/xml");
|
|
75
|
+
return { success: true, filename };
|
|
76
|
+
}
|
|
77
|
+
case "fit": {
|
|
78
|
+
if (!isFitSupported(workout.sport)) {
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
filename: "",
|
|
82
|
+
error: `FIT export not supported for ${workout.sport} workouts`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const fitContent = await generateFit(workout, settings);
|
|
86
|
+
const filename = `${safeName}.fit`;
|
|
87
|
+
downloadFile(fitContent, filename, "application/vnd.ant.fit");
|
|
88
|
+
return { success: true, filename };
|
|
89
|
+
}
|
|
90
|
+
case "mrc": {
|
|
91
|
+
if (!isErgSupported(workout.sport)) {
|
|
92
|
+
return {
|
|
93
|
+
success: false,
|
|
94
|
+
filename: "",
|
|
95
|
+
error: `MRC export only supports bike workouts`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const mrcContent = generateMrc(workout, settings);
|
|
99
|
+
const filename = `${safeName}.mrc`;
|
|
100
|
+
downloadFile(mrcContent, filename, "text/plain");
|
|
101
|
+
return { success: true, filename };
|
|
102
|
+
}
|
|
103
|
+
default:
|
|
104
|
+
return {
|
|
105
|
+
success: false,
|
|
106
|
+
filename: "",
|
|
107
|
+
error: `Unknown format: ${format}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
filename: "",
|
|
115
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Export the full training plan to iCalendar format
|
|
121
|
+
*/
|
|
122
|
+
export function exportPlanToCalendar(plan) {
|
|
123
|
+
try {
|
|
124
|
+
const icsContent = generateIcs(plan);
|
|
125
|
+
const safeName = sanitizeFilename(plan.meta.event);
|
|
126
|
+
const filename = `${safeName}_training_plan.ics`;
|
|
127
|
+
downloadFile(icsContent, filename, "text/calendar");
|
|
128
|
+
return { success: true, filename };
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
success: false,
|
|
133
|
+
filename: "",
|
|
134
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Generate workout file content without triggering a download
|
|
140
|
+
*/
|
|
141
|
+
async function generateWorkoutContent(workout, format, settings) {
|
|
142
|
+
const safeName = sanitizeFilename(workout.name);
|
|
143
|
+
try {
|
|
144
|
+
switch (format) {
|
|
145
|
+
case "zwo": {
|
|
146
|
+
if (!isZwoSupported(workout.sport))
|
|
147
|
+
return null;
|
|
148
|
+
const content = generateZwo(workout, settings);
|
|
149
|
+
return { content, filename: `${safeName}.zwo` };
|
|
150
|
+
}
|
|
151
|
+
case "fit": {
|
|
152
|
+
if (!isFitSupported(workout.sport))
|
|
153
|
+
return null;
|
|
154
|
+
const content = await generateFit(workout, settings);
|
|
155
|
+
return { content, filename: `${safeName}.fit` };
|
|
156
|
+
}
|
|
157
|
+
case "mrc": {
|
|
158
|
+
if (!isErgSupported(workout.sport))
|
|
159
|
+
return null;
|
|
160
|
+
const content = generateMrc(workout, settings);
|
|
161
|
+
return { content, filename: `${safeName}.mrc` };
|
|
162
|
+
}
|
|
163
|
+
default:
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Export all workouts in the plan to a single ZIP file
|
|
173
|
+
* Contains ZWO/FIT/MRC files based on the selected format
|
|
174
|
+
*/
|
|
175
|
+
export async function exportAllWorkouts(plan, format, settings) {
|
|
176
|
+
const errors = [];
|
|
177
|
+
let exported = 0;
|
|
178
|
+
let skipped = 0;
|
|
179
|
+
const zip = new JSZip();
|
|
180
|
+
for (const week of plan.weeks) {
|
|
181
|
+
for (const day of week.days) {
|
|
182
|
+
for (const workout of day.workouts) {
|
|
183
|
+
// Skip rest days
|
|
184
|
+
if (workout.sport === "rest") {
|
|
185
|
+
skipped++;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// Check format support
|
|
189
|
+
if (format === "zwo" && !isZwoSupported(workout.sport)) {
|
|
190
|
+
skipped++;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (format === "fit" && !isFitSupported(workout.sport)) {
|
|
194
|
+
skipped++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (format === "mrc" && !isErgSupported(workout.sport)) {
|
|
198
|
+
skipped++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const result = await generateWorkoutContent(workout, format, settings);
|
|
203
|
+
if (result) {
|
|
204
|
+
zip.file(result.filename, result.content);
|
|
205
|
+
exported++;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
skipped++;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
errors.push(`${workout.name}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Only create and download the ZIP if there are files to export
|
|
218
|
+
if (exported > 0) {
|
|
219
|
+
const zipContent = await zip.generateAsync({ type: "uint8array" });
|
|
220
|
+
const planName = sanitizeFilename(plan.meta.event);
|
|
221
|
+
const zipFilename = `${planName}_workouts_${format}.zip`;
|
|
222
|
+
downloadFile(zipContent, zipFilename, "application/zip");
|
|
223
|
+
}
|
|
224
|
+
return { exported, skipped, errors };
|
|
225
|
+
}
|
|
226
|
+
// Re-export format-specific helpers
|
|
227
|
+
export { isZwoSupported } from "./zwo.js";
|
|
228
|
+
export { isFitSupported, isFitSdkAvailable } from "./fit.js";
|
|
229
|
+
export { isErgSupported } from "./erg.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZWO (Zwift Workout) Export
|
|
3
|
+
*
|
|
4
|
+
* Generates Zwift workout files in XML format.
|
|
5
|
+
* Only supports bike and run workouts.
|
|
6
|
+
*
|
|
7
|
+
* How to import to Zwift:
|
|
8
|
+
* - Desktop: Save to Documents/Zwift/Workouts/[userID]/
|
|
9
|
+
* - iOS/iPad: Open Zwift on desktop first (syncs to cloud), then iOS downloads it
|
|
10
|
+
* - Alternative: Save to iCloud at Documents/Zwift/Workouts/[userID]/
|
|
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 Zwift export
|
|
16
|
+
*/
|
|
17
|
+
export declare function isZwoSupported(sport: Sport): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Generate a complete ZWO file for a workout
|
|
20
|
+
*/
|
|
21
|
+
export declare function generateZwo(workout: Workout, _settings: Settings): string;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZWO (Zwift Workout) Export
|
|
3
|
+
*
|
|
4
|
+
* Generates Zwift workout files in XML format.
|
|
5
|
+
* Only supports bike and run workouts.
|
|
6
|
+
*
|
|
7
|
+
* How to import to Zwift:
|
|
8
|
+
* - Desktop: Save to Documents/Zwift/Workouts/[userID]/
|
|
9
|
+
* - iOS/iPad: Open Zwift on desktop first (syncs to cloud), then iOS downloads it
|
|
10
|
+
* - Alternative: Save to iCloud at Documents/Zwift/Workouts/[userID]/
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Check if a sport is supported by Zwift export
|
|
14
|
+
*/
|
|
15
|
+
export function isZwoSupported(sport) {
|
|
16
|
+
return sport === "bike" || sport === "run";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Escape XML special characters
|
|
20
|
+
*/
|
|
21
|
+
function escapeXml(str) {
|
|
22
|
+
return str
|
|
23
|
+
.replace(/&/g, "&")
|
|
24
|
+
.replace(/</g, "<")
|
|
25
|
+
.replace(/>/g, ">")
|
|
26
|
+
.replace(/"/g, """)
|
|
27
|
+
.replace(/'/g, "'");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Convert intensity percentage to Zwift decimal format
|
|
31
|
+
* (e.g., 75% FTP -> 0.75)
|
|
32
|
+
*/
|
|
33
|
+
function intensityToDecimal(intensity) {
|
|
34
|
+
// Intensity is stored as percentage (e.g., 75 for 75%)
|
|
35
|
+
// Zwift expects decimal (0.75)
|
|
36
|
+
return intensity / 100;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Convert duration to seconds
|
|
40
|
+
*/
|
|
41
|
+
function durationToSeconds(value, unit) {
|
|
42
|
+
switch (unit) {
|
|
43
|
+
case "seconds":
|
|
44
|
+
return value;
|
|
45
|
+
case "minutes":
|
|
46
|
+
return value * 60;
|
|
47
|
+
case "hours":
|
|
48
|
+
return value * 3600;
|
|
49
|
+
default:
|
|
50
|
+
// For distance-based durations, estimate using typical speeds
|
|
51
|
+
// This is a rough approximation
|
|
52
|
+
return value; // Return as-is if not time-based
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get workout type for ZWO (bike or run)
|
|
57
|
+
*/
|
|
58
|
+
function getZwoSportType(sport) {
|
|
59
|
+
return sport === "run" ? "run" : "bike";
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Generate a warmup segment
|
|
63
|
+
*/
|
|
64
|
+
function generateWarmup(step) {
|
|
65
|
+
const duration = durationToSeconds(step.duration.value, step.duration.unit);
|
|
66
|
+
const startPower = intensityToDecimal(step.intensity.valueLow || step.intensity.value * 0.6);
|
|
67
|
+
const endPower = intensityToDecimal(step.intensity.valueHigh || step.intensity.value);
|
|
68
|
+
return ` <Warmup Duration="${duration}" PowerLow="${startPower.toFixed(2)}" PowerHigh="${endPower.toFixed(2)}"/>`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Generate a cooldown segment
|
|
72
|
+
*/
|
|
73
|
+
function generateCooldown(step) {
|
|
74
|
+
const duration = durationToSeconds(step.duration.value, step.duration.unit);
|
|
75
|
+
const startPower = intensityToDecimal(step.intensity.valueHigh || step.intensity.value);
|
|
76
|
+
const endPower = intensityToDecimal(step.intensity.valueLow || step.intensity.value * 0.5);
|
|
77
|
+
return ` <Cooldown Duration="${duration}" PowerLow="${endPower.toFixed(2)}" PowerHigh="${startPower.toFixed(2)}"/>`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Generate a steady state segment
|
|
81
|
+
*/
|
|
82
|
+
function generateSteadyState(step, isRamp = false) {
|
|
83
|
+
const duration = durationToSeconds(step.duration.value, step.duration.unit);
|
|
84
|
+
const power = intensityToDecimal(step.intensity.value);
|
|
85
|
+
let cadenceAttr = "";
|
|
86
|
+
if (step.cadence) {
|
|
87
|
+
cadenceAttr = ` Cadence="${step.cadence.low}"`;
|
|
88
|
+
if (step.cadence.high !== step.cadence.low) {
|
|
89
|
+
cadenceAttr = ` CadenceLow="${step.cadence.low}" CadenceHigh="${step.cadence.high}"`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (isRamp && step.intensity.valueLow !== undefined && step.intensity.valueHigh !== undefined) {
|
|
93
|
+
const powerLow = intensityToDecimal(step.intensity.valueLow);
|
|
94
|
+
const powerHigh = intensityToDecimal(step.intensity.valueHigh);
|
|
95
|
+
return ` <Ramp Duration="${duration}" PowerLow="${powerLow.toFixed(2)}" PowerHigh="${powerHigh.toFixed(2)}"${cadenceAttr}/>`;
|
|
96
|
+
}
|
|
97
|
+
return ` <SteadyState Duration="${duration}" Power="${power.toFixed(2)}"${cadenceAttr}/>`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Generate an interval set (IntervalsT in ZWO)
|
|
101
|
+
*/
|
|
102
|
+
function generateIntervalSet(intervalSet) {
|
|
103
|
+
// Find work and recovery steps
|
|
104
|
+
const workStep = intervalSet.steps.find((s) => s.type === "work");
|
|
105
|
+
const recoveryStep = intervalSet.steps.find((s) => s.type === "recovery" || s.type === "rest");
|
|
106
|
+
if (!workStep) {
|
|
107
|
+
// Just generate steady states if no work step found
|
|
108
|
+
return intervalSet.steps.map((s) => generateSteadyState(s)).join("\n");
|
|
109
|
+
}
|
|
110
|
+
const onDuration = durationToSeconds(workStep.duration.value, workStep.duration.unit);
|
|
111
|
+
const onPower = intensityToDecimal(workStep.intensity.value);
|
|
112
|
+
const offDuration = recoveryStep
|
|
113
|
+
? durationToSeconds(recoveryStep.duration.value, recoveryStep.duration.unit)
|
|
114
|
+
: 60; // Default 60s recovery
|
|
115
|
+
const offPower = recoveryStep ? intensityToDecimal(recoveryStep.intensity.value) : 0.5; // Default 50% recovery
|
|
116
|
+
let cadenceAttr = "";
|
|
117
|
+
if (workStep.cadence) {
|
|
118
|
+
cadenceAttr = ` Cadence="${workStep.cadence.low}"`;
|
|
119
|
+
}
|
|
120
|
+
return ` <IntervalsT Repeat="${intervalSet.repeats}" OnDuration="${onDuration}" OffDuration="${offDuration}" OnPower="${onPower.toFixed(2)}" OffPower="${offPower.toFixed(2)}"${cadenceAttr}/>`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Generate workout segments from structured workout
|
|
124
|
+
*/
|
|
125
|
+
function generateSegmentsFromStructure(structure) {
|
|
126
|
+
const segments = [];
|
|
127
|
+
// Warmup
|
|
128
|
+
if (structure.warmup && structure.warmup.length > 0) {
|
|
129
|
+
for (const step of structure.warmup) {
|
|
130
|
+
if (step.type === "warmup") {
|
|
131
|
+
segments.push(generateWarmup(step));
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
segments.push(generateSteadyState(step));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Main set
|
|
139
|
+
for (const item of structure.main) {
|
|
140
|
+
if ("repeats" in item) {
|
|
141
|
+
// Interval set
|
|
142
|
+
segments.push(generateIntervalSet(item));
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
// Single step
|
|
146
|
+
const step = item;
|
|
147
|
+
segments.push(generateSteadyState(step));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Cooldown
|
|
151
|
+
if (structure.cooldown && structure.cooldown.length > 0) {
|
|
152
|
+
for (const step of structure.cooldown) {
|
|
153
|
+
if (step.type === "cooldown") {
|
|
154
|
+
segments.push(generateCooldown(step));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
segments.push(generateSteadyState(step));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return segments;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Generate a simple workout when no structure is provided
|
|
165
|
+
* Creates warmup -> main -> cooldown based on duration
|
|
166
|
+
*/
|
|
167
|
+
function generateSimpleWorkout(workout) {
|
|
168
|
+
const segments = [];
|
|
169
|
+
const totalMinutes = workout.durationMinutes || 60;
|
|
170
|
+
// 10% warmup (min 5 min, max 15 min)
|
|
171
|
+
const warmupMinutes = Math.min(15, Math.max(5, Math.round(totalMinutes * 0.1)));
|
|
172
|
+
segments.push(` <Warmup Duration="${warmupMinutes * 60}" PowerLow="0.40" PowerHigh="0.65"/>`);
|
|
173
|
+
// Main set based on workout type
|
|
174
|
+
let mainPower = 0.65; // Default endurance
|
|
175
|
+
switch (workout.type) {
|
|
176
|
+
case "recovery":
|
|
177
|
+
mainPower = 0.55;
|
|
178
|
+
break;
|
|
179
|
+
case "endurance":
|
|
180
|
+
mainPower = 0.65;
|
|
181
|
+
break;
|
|
182
|
+
case "tempo":
|
|
183
|
+
mainPower = 0.8;
|
|
184
|
+
break;
|
|
185
|
+
case "threshold":
|
|
186
|
+
mainPower = 0.95;
|
|
187
|
+
break;
|
|
188
|
+
case "vo2max":
|
|
189
|
+
mainPower = 1.1;
|
|
190
|
+
break;
|
|
191
|
+
case "intervals":
|
|
192
|
+
mainPower = 0.85;
|
|
193
|
+
break;
|
|
194
|
+
case "long":
|
|
195
|
+
mainPower = 0.65;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
// 10% cooldown (min 5 min, max 10 min)
|
|
199
|
+
const cooldownMinutes = Math.min(10, Math.max(5, Math.round(totalMinutes * 0.1)));
|
|
200
|
+
const mainMinutes = totalMinutes - warmupMinutes - cooldownMinutes;
|
|
201
|
+
segments.push(` <SteadyState Duration="${mainMinutes * 60}" Power="${mainPower.toFixed(2)}"/>`);
|
|
202
|
+
segments.push(` <Cooldown Duration="${cooldownMinutes * 60}" PowerLow="0.40" PowerHigh="0.60"/>`);
|
|
203
|
+
return segments;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Generate a complete ZWO file for a workout
|
|
207
|
+
*/
|
|
208
|
+
export function generateZwo(workout, _settings) {
|
|
209
|
+
if (!isZwoSupported(workout.sport)) {
|
|
210
|
+
throw new Error(`ZWO export not supported for ${workout.sport} workouts`);
|
|
211
|
+
}
|
|
212
|
+
const sportType = getZwoSportType(workout.sport);
|
|
213
|
+
const segments = workout.structure
|
|
214
|
+
? generateSegmentsFromStructure(workout.structure)
|
|
215
|
+
: generateSimpleWorkout(workout);
|
|
216
|
+
// Clean up description for XML
|
|
217
|
+
const description = escapeXml((workout.description || "") +
|
|
218
|
+
(workout.humanReadable ? `\n\n${workout.humanReadable.replace(/\\n/g, "\n")}` : ""));
|
|
219
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
220
|
+
<workout_file>
|
|
221
|
+
<author>Claude Coach</author>
|
|
222
|
+
<name>${escapeXml(workout.name)}</name>
|
|
223
|
+
<description>${description}</description>
|
|
224
|
+
<sportType>${sportType}</sportType>
|
|
225
|
+
<workout>
|
|
226
|
+
${segments.join("\n")}
|
|
227
|
+
</workout>
|
|
228
|
+
</workout_file>`;
|
|
229
|
+
return xml;
|
|
230
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Settings } from "../stores/settings.js";
|
|
2
|
+
import type { Sport } from "../../schema/training-plan.js";
|
|
3
|
+
export declare function formatDuration(minutes: number | undefined): string;
|
|
4
|
+
export declare function formatDistance(meters: number | undefined, sport: Sport, settings: Settings): string;
|
|
5
|
+
export declare function formatDate(dateStr: string): string;
|
|
6
|
+
export declare function formatEventDate(dateStr: string): string;
|
|
7
|
+
export declare function getZoneInfo(sport: Sport, zoneStr: string | undefined, settings: Settings): string;
|
|
8
|
+
export declare function getDaysToEvent(eventDate: string): number;
|
|
9
|
+
export declare function getOrderedDays(firstDayOfWeek: "monday" | "sunday"): string[];
|
|
10
|
+
export declare function getTodayISO(): string;
|
|
11
|
+
export declare function formatDateISO(date: Date): string;
|
|
12
|
+
export declare function parseDate(dateStr: string): Date;
|
|
13
|
+
export declare function getSportColor(sport: Sport): string;
|
|
14
|
+
export declare function getSportIcon(sport: Sport): string;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const METERS_PER_YARD = 0.9144;
|
|
2
|
+
const KM_PER_MILE = 1.60934;
|
|
3
|
+
export function formatDuration(minutes) {
|
|
4
|
+
if (!minutes)
|
|
5
|
+
return "";
|
|
6
|
+
const h = Math.floor(minutes / 60);
|
|
7
|
+
const m = minutes % 60;
|
|
8
|
+
if (h > 0) {
|
|
9
|
+
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
10
|
+
}
|
|
11
|
+
return `${m}m`;
|
|
12
|
+
}
|
|
13
|
+
export function formatDistance(meters, sport, settings) {
|
|
14
|
+
if (!meters)
|
|
15
|
+
return "";
|
|
16
|
+
if (sport === "swim") {
|
|
17
|
+
if (settings.units.swim === "yards") {
|
|
18
|
+
const yards = meters / METERS_PER_YARD;
|
|
19
|
+
return `${Math.round(yards)}yd`;
|
|
20
|
+
}
|
|
21
|
+
return `${Math.round(meters)}m`;
|
|
22
|
+
}
|
|
23
|
+
if (sport === "bike" || sport === "run") {
|
|
24
|
+
const unit = settings.units[sport];
|
|
25
|
+
if (unit === "miles") {
|
|
26
|
+
const miles = meters / 1000 / KM_PER_MILE;
|
|
27
|
+
return miles >= 10 ? `${Math.round(miles)}mi` : `${miles.toFixed(1)}mi`;
|
|
28
|
+
}
|
|
29
|
+
const km = meters / 1000;
|
|
30
|
+
return km >= 10 ? `${Math.round(km)}km` : `${km.toFixed(1)}km`;
|
|
31
|
+
}
|
|
32
|
+
return `${Math.round(meters)}m`;
|
|
33
|
+
}
|
|
34
|
+
export function formatDate(dateStr) {
|
|
35
|
+
const date = new Date(dateStr);
|
|
36
|
+
return date.toLocaleDateString("en-US", {
|
|
37
|
+
weekday: "long",
|
|
38
|
+
month: "short",
|
|
39
|
+
day: "numeric",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export function formatEventDate(dateStr) {
|
|
43
|
+
const date = new Date(dateStr);
|
|
44
|
+
return date.toLocaleDateString("en-US", {
|
|
45
|
+
weekday: "long",
|
|
46
|
+
month: "long",
|
|
47
|
+
day: "numeric",
|
|
48
|
+
year: "numeric",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function getZoneInfo(sport, zoneStr, settings) {
|
|
52
|
+
if (!zoneStr)
|
|
53
|
+
return "";
|
|
54
|
+
const match = zoneStr.match(/(?:Zone\s*)?(\d)(?:\s*-\s*(\d))?/i);
|
|
55
|
+
if (!match)
|
|
56
|
+
return zoneStr;
|
|
57
|
+
const z1 = parseInt(match[1]);
|
|
58
|
+
const z2 = match[2] ? parseInt(match[2]) : z1;
|
|
59
|
+
let info = zoneStr;
|
|
60
|
+
if (sport === "run" || sport === "bike") {
|
|
61
|
+
const hrZones = settings[sport]?.hrZones;
|
|
62
|
+
if (hrZones && hrZones[z1 - 1]) {
|
|
63
|
+
const low = hrZones[z1 - 1].low;
|
|
64
|
+
const high = z2 !== z1 && hrZones[z2 - 1] ? hrZones[z2 - 1].high : hrZones[z1 - 1].high;
|
|
65
|
+
info += ` (${low}-${high} bpm)`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return info;
|
|
69
|
+
}
|
|
70
|
+
export function getDaysToEvent(eventDate) {
|
|
71
|
+
const daysLeft = Math.ceil((new Date(eventDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
|
|
72
|
+
return Math.max(0, daysLeft);
|
|
73
|
+
}
|
|
74
|
+
export function getOrderedDays(firstDayOfWeek) {
|
|
75
|
+
if (firstDayOfWeek === "monday") {
|
|
76
|
+
return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
77
|
+
}
|
|
78
|
+
return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
79
|
+
}
|
|
80
|
+
export function getTodayISO() {
|
|
81
|
+
return formatDateISO(new Date());
|
|
82
|
+
}
|
|
83
|
+
// Format date as YYYY-MM-DD without timezone conversion
|
|
84
|
+
export function formatDateISO(date) {
|
|
85
|
+
const year = date.getFullYear();
|
|
86
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
87
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
88
|
+
return `${year}-${month}-${day}`;
|
|
89
|
+
}
|
|
90
|
+
// Parse ISO date string to Date object (at midnight local time)
|
|
91
|
+
export function parseDate(dateStr) {
|
|
92
|
+
const [year, month, day] = dateStr.split("-").map(Number);
|
|
93
|
+
return new Date(year, month - 1, day);
|
|
94
|
+
}
|
|
95
|
+
export function getSportColor(sport) {
|
|
96
|
+
const colors = {
|
|
97
|
+
swim: "var(--swim)",
|
|
98
|
+
bike: "var(--bike)",
|
|
99
|
+
run: "var(--run)",
|
|
100
|
+
strength: "var(--strength)",
|
|
101
|
+
brick: "var(--brick)",
|
|
102
|
+
race: "var(--race)",
|
|
103
|
+
rest: "var(--rest)",
|
|
104
|
+
};
|
|
105
|
+
return colors[sport] || "var(--text-muted)";
|
|
106
|
+
}
|
|
107
|
+
export function getSportIcon(sport) {
|
|
108
|
+
const icons = {
|
|
109
|
+
swim: "\u{1F3CA}",
|
|
110
|
+
bike: "\u{1F6B4}",
|
|
111
|
+
run: "\u{1F3C3}",
|
|
112
|
+
strength: "\u{1F4AA}",
|
|
113
|
+
brick: "\u{1F525}",
|
|
114
|
+
race: "\u{1F3C6}",
|
|
115
|
+
rest: "\u{1F6CC}",
|
|
116
|
+
};
|
|
117
|
+
return icons[sport] || "";
|
|
118
|
+
}
|