endurance-coach 0.1.1 → 1.0.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/README.md +3 -0
- package/dist/cli.js +318 -35
- package/dist/expander/expander.d.ts +20 -0
- package/dist/expander/expander.js +339 -0
- package/dist/expander/index.d.ts +8 -0
- package/dist/expander/index.js +9 -0
- package/dist/expander/types.d.ts +169 -0
- package/dist/expander/types.js +6 -0
- package/dist/expander/zones.d.ts +50 -0
- package/dist/expander/zones.js +159 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +9 -1
- package/dist/schema/compact-plan.d.ts +175 -0
- package/dist/schema/compact-plan.js +64 -0
- package/dist/schema/compact-plan.schema.d.ts +277 -0
- package/dist/schema/compact-plan.schema.js +205 -0
- package/dist/templates/index.d.ts +10 -0
- package/dist/templates/index.js +13 -0
- package/dist/templates/interpolate.d.ts +51 -0
- package/dist/templates/interpolate.js +204 -0
- package/dist/templates/loader.d.ts +19 -0
- package/dist/templates/loader.js +129 -0
- package/dist/templates/template.schema.d.ts +401 -0
- package/dist/templates/template.schema.js +101 -0
- package/dist/templates/template.types.d.ts +155 -0
- package/dist/templates/template.types.js +7 -0
- package/dist/templates/yaml-parser.d.ts +15 -0
- package/dist/templates/yaml-parser.js +18 -0
- package/package.json +2 -1
- package/templates/bike/CLAUDE.md +7 -0
- package/templates/bike/easy.yaml +38 -0
- package/templates/bike/endurance.yaml +42 -0
- package/templates/bike/hills.yaml +80 -0
- package/templates/bike/overunders.yaml +81 -0
- package/templates/bike/rest.yaml +16 -0
- package/templates/bike/sweetspot.yaml +80 -0
- package/templates/bike/tempo.yaml +79 -0
- package/templates/bike/threshold.yaml +83 -0
- package/templates/bike/vo2max.yaml +84 -0
- package/templates/brick/CLAUDE.md +7 -0
- package/templates/brick/halfironman.yaml +72 -0
- package/templates/brick/ironman.yaml +72 -0
- package/templates/brick/olympic.yaml +70 -0
- package/templates/brick/sprint.yaml +70 -0
- package/templates/plan-viewer.html +22 -22
- package/templates/run/CLAUDE.md +7 -0
- package/templates/run/easy.yaml +36 -0
- package/templates/run/fartlek.yaml +40 -0
- package/templates/run/hills.yaml +36 -0
- package/templates/run/intervals.1k.yaml +63 -0
- package/templates/run/intervals.400.yaml +63 -0
- package/templates/run/intervals.800.yaml +63 -0
- package/templates/run/intervals.mile.yaml +64 -0
- package/templates/run/long.yaml +41 -0
- package/templates/run/progression.yaml +49 -0
- package/templates/run/race.5k.yaml +36 -0
- package/templates/run/recovery.yaml +36 -0
- package/templates/run/rest.yaml +16 -0
- package/templates/run/strides.yaml +49 -0
- package/templates/run/tempo.yaml +56 -0
- package/templates/run/threshold.yaml +56 -0
- package/templates/strength/CLAUDE.md +7 -0
- package/templates/strength/core.yaml +56 -0
- package/templates/strength/foundation.yaml +65 -0
- package/templates/strength/full.yaml +73 -0
- package/templates/strength/maintenance.yaml +62 -0
- package/templates/swim/CLAUDE.md +7 -0
- package/templates/swim/aerobic.yaml +67 -0
- package/templates/swim/easy.yaml +51 -0
- package/templates/swim/openwater.yaml +60 -0
- package/templates/swim/rest.yaml +16 -0
- package/templates/swim/technique.yaml +67 -0
- package/templates/swim/threshold.yaml +75 -0
- package/templates/swim/vo2max.yaml +88 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Expander
|
|
3
|
+
*
|
|
4
|
+
* Converts compact training plans to expanded format for HTML rendering.
|
|
5
|
+
*/
|
|
6
|
+
import { parseWorkoutRef, parseWeekRange } from "../schema/compact-plan.js";
|
|
7
|
+
import { interpolate, evaluateExpression, createContext } from "../templates/index.js";
|
|
8
|
+
import { calculateAthleteZones } from "./zones.js";
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Date Utilities
|
|
11
|
+
// ============================================================================
|
|
12
|
+
/**
|
|
13
|
+
* Get the day of week name from a Date.
|
|
14
|
+
*/
|
|
15
|
+
function getDayOfWeekName(date) {
|
|
16
|
+
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
17
|
+
return days[date.getDay()];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Format a Date as ISO date string (YYYY-MM-DD).
|
|
21
|
+
*/
|
|
22
|
+
function formatDate(date) {
|
|
23
|
+
return date.toISOString().split("T")[0];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Add days to a date.
|
|
27
|
+
*/
|
|
28
|
+
function addDays(date, days) {
|
|
29
|
+
const result = new Date(date);
|
|
30
|
+
result.setDate(result.getDate() + days);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Calculate the start date of the plan from the event date and total weeks.
|
|
35
|
+
*/
|
|
36
|
+
function calculateStartDate(eventDate, totalWeeks, firstDayOfWeek) {
|
|
37
|
+
const event = new Date(eventDate);
|
|
38
|
+
// Go back totalWeeks * 7 days from event date
|
|
39
|
+
const start = addDays(event, -(totalWeeks * 7));
|
|
40
|
+
// Adjust to the first day of the week
|
|
41
|
+
const targetDay = firstDayOfWeek === "monday" ? 1 : 0;
|
|
42
|
+
const currentDay = start.getDay();
|
|
43
|
+
const diff = currentDay - targetDay;
|
|
44
|
+
const adjustedDiff = diff < 0 ? diff + 7 : diff;
|
|
45
|
+
return addDays(start, -adjustedDiff);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Map compact day abbreviation to day index (0-6, starting from Sunday).
|
|
49
|
+
*/
|
|
50
|
+
function dayAbbrevToIndex(abbrev) {
|
|
51
|
+
const mapping = {
|
|
52
|
+
Sun: 0,
|
|
53
|
+
Mon: 1,
|
|
54
|
+
Tue: 2,
|
|
55
|
+
Wed: 3,
|
|
56
|
+
Thu: 4,
|
|
57
|
+
Fri: 5,
|
|
58
|
+
Sat: 6,
|
|
59
|
+
};
|
|
60
|
+
return mapping[abbrev] ?? -1;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Get the offset from the first day of week to a specific day.
|
|
64
|
+
*/
|
|
65
|
+
function getDayOffset(dayAbbrev, firstDayOfWeek) {
|
|
66
|
+
const dayIndex = dayAbbrevToIndex(dayAbbrev);
|
|
67
|
+
const firstDayIndex = firstDayOfWeek === "monday" ? 1 : 0;
|
|
68
|
+
let offset = dayIndex - firstDayIndex;
|
|
69
|
+
if (offset < 0)
|
|
70
|
+
offset += 7;
|
|
71
|
+
return offset;
|
|
72
|
+
}
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Workout Expansion
|
|
75
|
+
// ============================================================================
|
|
76
|
+
/**
|
|
77
|
+
* Parse a workout reference and extract the template ID and parameters.
|
|
78
|
+
*/
|
|
79
|
+
function parseWorkoutReference(ref) {
|
|
80
|
+
return parseWorkoutRef(ref);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Expand a single workout from its template reference.
|
|
84
|
+
*/
|
|
85
|
+
export function expandWorkout(ref, workoutId, context, templates) {
|
|
86
|
+
const parsed = parseWorkoutReference(ref);
|
|
87
|
+
const template = templates.get(parsed.templateId);
|
|
88
|
+
if (!template) {
|
|
89
|
+
// Create a placeholder workout for unknown templates
|
|
90
|
+
return {
|
|
91
|
+
id: workoutId,
|
|
92
|
+
sport: "run",
|
|
93
|
+
type: "unknown",
|
|
94
|
+
name: `Unknown: ${parsed.templateId}`,
|
|
95
|
+
humanReadable: `Template not found: ${parsed.templateId}`,
|
|
96
|
+
completed: false,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// Build the full context with template params
|
|
100
|
+
const paramContext = buildParamContext(template, parsed.params);
|
|
101
|
+
const fullContext = {
|
|
102
|
+
...context,
|
|
103
|
+
...paramContext,
|
|
104
|
+
};
|
|
105
|
+
// Interpolate the human-readable description
|
|
106
|
+
const humanReadable = interpolate(template.humanReadable, fullContext);
|
|
107
|
+
// Calculate duration
|
|
108
|
+
let durationMinutes;
|
|
109
|
+
if (template.estimatedDuration !== undefined) {
|
|
110
|
+
if (typeof template.estimatedDuration === "number") {
|
|
111
|
+
durationMinutes = template.estimatedDuration;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const result = evaluateExpression(template.estimatedDuration, fullContext);
|
|
115
|
+
if (typeof result === "number") {
|
|
116
|
+
durationMinutes = result;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const parsed = parseFloat(result);
|
|
120
|
+
if (!isNaN(parsed)) {
|
|
121
|
+
durationMinutes = parsed;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
id: workoutId,
|
|
128
|
+
sport: template.sport,
|
|
129
|
+
type: template.type,
|
|
130
|
+
name: template.name,
|
|
131
|
+
durationMinutes,
|
|
132
|
+
primaryZone: template.targetZone,
|
|
133
|
+
rpe: template.rpe,
|
|
134
|
+
humanReadable,
|
|
135
|
+
completed: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Build parameter context from template defaults and provided params.
|
|
140
|
+
*/
|
|
141
|
+
function buildParamContext(template, providedParams) {
|
|
142
|
+
const context = {};
|
|
143
|
+
if (!template.params) {
|
|
144
|
+
return context;
|
|
145
|
+
}
|
|
146
|
+
const paramNames = Object.keys(template.params);
|
|
147
|
+
// Apply defaults first
|
|
148
|
+
for (const [name, def] of Object.entries(template.params)) {
|
|
149
|
+
if (def.default !== undefined) {
|
|
150
|
+
context[name] = def.default;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Override with provided params (positional)
|
|
154
|
+
for (let i = 0; i < providedParams.length && i < paramNames.length; i++) {
|
|
155
|
+
context[paramNames[i]] = providedParams[i];
|
|
156
|
+
}
|
|
157
|
+
return context;
|
|
158
|
+
}
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// Week Expansion
|
|
161
|
+
// ============================================================================
|
|
162
|
+
/**
|
|
163
|
+
* Expand a single week from the compact format.
|
|
164
|
+
*/
|
|
165
|
+
function expandWeek(compactWeek, weekStartDate, context, templates, firstDayOfWeek) {
|
|
166
|
+
const days = [];
|
|
167
|
+
let totalMinutes = 0;
|
|
168
|
+
const bySport = {};
|
|
169
|
+
// Create all 7 days of the week
|
|
170
|
+
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
|
171
|
+
const date = addDays(weekStartDate, dayOffset);
|
|
172
|
+
const dayOfWeek = getDayOfWeekName(date);
|
|
173
|
+
days.push({
|
|
174
|
+
date: formatDate(date),
|
|
175
|
+
dayOfWeek,
|
|
176
|
+
workouts: [],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// Populate workouts from the schedule
|
|
180
|
+
for (const [dayAbbrev, workoutRefs] of Object.entries(compactWeek.workouts)) {
|
|
181
|
+
const dayOffset = getDayOffset(dayAbbrev, firstDayOfWeek);
|
|
182
|
+
if (dayOffset < 0 || dayOffset >= 7)
|
|
183
|
+
continue;
|
|
184
|
+
const day = days[dayOffset];
|
|
185
|
+
const refs = Array.isArray(workoutRefs) ? workoutRefs : [workoutRefs];
|
|
186
|
+
for (let i = 0; i < refs.length; i++) {
|
|
187
|
+
const ref = refs[i];
|
|
188
|
+
const workoutId = `week${compactWeek.week}-${dayAbbrev.toLowerCase()}-${i + 1}`;
|
|
189
|
+
const workout = expandWorkout(ref, workoutId, context, templates);
|
|
190
|
+
day.workouts.push(workout);
|
|
191
|
+
// Update summary stats
|
|
192
|
+
if (workout.durationMinutes) {
|
|
193
|
+
totalMinutes += workout.durationMinutes;
|
|
194
|
+
const sport = workout.sport;
|
|
195
|
+
if (!bySport[sport]) {
|
|
196
|
+
bySport[sport] = { sessions: 0, hours: 0 };
|
|
197
|
+
}
|
|
198
|
+
bySport[sport].sessions += 1;
|
|
199
|
+
bySport[sport].hours += workout.durationMinutes / 60;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Round hours to 1 decimal place
|
|
204
|
+
for (const sport of Object.keys(bySport)) {
|
|
205
|
+
bySport[sport].hours = Math.round(bySport[sport].hours * 10) / 10;
|
|
206
|
+
}
|
|
207
|
+
const summary = {
|
|
208
|
+
totalHours: Math.round((totalMinutes / 60) * 10) / 10,
|
|
209
|
+
bySport,
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
weekNumber: compactWeek.week,
|
|
213
|
+
startDate: formatDate(weekStartDate),
|
|
214
|
+
endDate: formatDate(addDays(weekStartDate, 6)),
|
|
215
|
+
phase: compactWeek.phase,
|
|
216
|
+
focus: compactWeek.focus || "",
|
|
217
|
+
targetHours: compactWeek.targetHours || summary.totalHours,
|
|
218
|
+
days,
|
|
219
|
+
summary,
|
|
220
|
+
isRecoveryWeek: compactWeek.isRecoveryWeek || false,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Phase Expansion
|
|
225
|
+
// ============================================================================
|
|
226
|
+
/**
|
|
227
|
+
* Expand phases from compact format.
|
|
228
|
+
*/
|
|
229
|
+
function expandPhases(compact) {
|
|
230
|
+
return compact.phases.map((phase) => {
|
|
231
|
+
const weeks = parseWeekRange(phase.weeks);
|
|
232
|
+
const startWeek = Math.min(...weeks);
|
|
233
|
+
const endWeek = Math.max(...weeks);
|
|
234
|
+
return {
|
|
235
|
+
name: phase.name,
|
|
236
|
+
startWeek,
|
|
237
|
+
endWeek,
|
|
238
|
+
focus: phase.focus,
|
|
239
|
+
weeklyHoursRange: { low: 0, high: 0 }, // Will be calculated from weeks
|
|
240
|
+
keyWorkouts: phase.keyWorkouts || [],
|
|
241
|
+
physiologicalGoals: [],
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// Main Expander
|
|
247
|
+
// ============================================================================
|
|
248
|
+
/**
|
|
249
|
+
* Expand a compact plan into the full format for HTML rendering.
|
|
250
|
+
*/
|
|
251
|
+
export function expandPlan(compact, templates, options = {}) {
|
|
252
|
+
const firstDayOfWeek = compact.athlete.firstDayOfWeek || "monday";
|
|
253
|
+
const totalWeeks = compact.weeks.length;
|
|
254
|
+
// Calculate start date
|
|
255
|
+
const startDate = options.startDate || calculateStartDate(compact.athlete.eventDate, totalWeeks, firstDayOfWeek);
|
|
256
|
+
// Build interpolation context
|
|
257
|
+
const zonesForContext = compact.athlete.zones?.hr
|
|
258
|
+
? {
|
|
259
|
+
hr: {
|
|
260
|
+
lthr: compact.athlete.zones.hr.lthr,
|
|
261
|
+
maxHR: compact.athlete.zones.hr.maxHR,
|
|
262
|
+
restingHR: compact.athlete.zones.hr.restingHR,
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
: undefined;
|
|
266
|
+
const context = createContext(compact.athlete.paces, zonesForContext);
|
|
267
|
+
// Calculate zones
|
|
268
|
+
const zones = calculateAthleteZones(compact.athlete.zones?.hr, compact.athlete.paces);
|
|
269
|
+
// Expand phases
|
|
270
|
+
const phases = expandPhases(compact);
|
|
271
|
+
// Expand weeks
|
|
272
|
+
const weeks = [];
|
|
273
|
+
for (let i = 0; i < compact.weeks.length; i++) {
|
|
274
|
+
const compactWeek = compact.weeks[i];
|
|
275
|
+
const weekStartDate = addDays(startDate, i * 7);
|
|
276
|
+
const expandedWeek = expandWeek(compactWeek, weekStartDate, context, templates, firstDayOfWeek);
|
|
277
|
+
weeks.push(expandedWeek);
|
|
278
|
+
}
|
|
279
|
+
// Calculate phase hour ranges from actual weeks
|
|
280
|
+
for (const phase of phases) {
|
|
281
|
+
const phaseWeeks = weeks.filter((w) => w.weekNumber >= phase.startWeek && w.weekNumber <= phase.endWeek);
|
|
282
|
+
if (phaseWeeks.length > 0) {
|
|
283
|
+
const hours = phaseWeeks.map((w) => w.summary.totalHours);
|
|
284
|
+
phase.weeklyHoursRange = {
|
|
285
|
+
low: Math.min(...hours),
|
|
286
|
+
high: Math.max(...hours),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Build metadata
|
|
291
|
+
const now = new Date().toISOString();
|
|
292
|
+
const meta = {
|
|
293
|
+
id: `plan-${Date.now()}`,
|
|
294
|
+
athlete: compact.athlete.name,
|
|
295
|
+
event: compact.athlete.event,
|
|
296
|
+
eventDate: compact.athlete.eventDate,
|
|
297
|
+
planStartDate: formatDate(startDate),
|
|
298
|
+
planEndDate: formatDate(addDays(startDate, totalWeeks * 7 - 1)),
|
|
299
|
+
createdAt: now,
|
|
300
|
+
updatedAt: now,
|
|
301
|
+
totalWeeks,
|
|
302
|
+
generatedBy: "Endurance Coach",
|
|
303
|
+
};
|
|
304
|
+
// Build preferences
|
|
305
|
+
const unit = compact.athlete.unit || "km";
|
|
306
|
+
const preferences = {
|
|
307
|
+
swim: "meters",
|
|
308
|
+
bike: unit === "mi" ? "miles" : "kilometers",
|
|
309
|
+
run: unit === "mi" ? "miles" : "kilometers",
|
|
310
|
+
firstDayOfWeek,
|
|
311
|
+
};
|
|
312
|
+
return {
|
|
313
|
+
version: "1.0",
|
|
314
|
+
meta,
|
|
315
|
+
preferences,
|
|
316
|
+
zones,
|
|
317
|
+
phases,
|
|
318
|
+
weeks,
|
|
319
|
+
raceStrategy: compact.raceStrategy ? { ...compact.raceStrategy } : undefined,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Validate that all workout references in a compact plan have corresponding templates.
|
|
324
|
+
*/
|
|
325
|
+
export function validateWorkoutRefs(compact, templates) {
|
|
326
|
+
const errors = [];
|
|
327
|
+
for (const week of compact.weeks) {
|
|
328
|
+
for (const [day, refs] of Object.entries(week.workouts)) {
|
|
329
|
+
const refArray = Array.isArray(refs) ? refs : [refs];
|
|
330
|
+
for (const ref of refArray) {
|
|
331
|
+
const parsed = parseWorkoutReference(ref);
|
|
332
|
+
if (!templates.has(parsed.templateId)) {
|
|
333
|
+
errors.push(`Week ${week.week}, ${day}: Unknown template "${parsed.templateId}"`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return errors;
|
|
339
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expander Module
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all expander-related types and functions.
|
|
5
|
+
*/
|
|
6
|
+
export type { ExpandedPlan, ExpandedWeek, ExpandedDay, ExpandedWorkout, ExpandedPhase, ExpandedWeekSummary, ExpandedHRZones, ExpandedHRZone, ExpandedPaceZones, ExpandedPaceZone, ExpandedAthleteZones, ExpandedPlanMeta, ExpandedUnitPreferences, ExpansionOptions, } from "./types.js";
|
|
7
|
+
export { expandPlan, expandWorkout, validateWorkoutRefs } from "./expander.js";
|
|
8
|
+
export { calculateHRZones, calculatePaceZones, calculateAthleteZones, parsePace, formatPace, getHRZoneForValue, getPaceZoneForValue, } from "./zones.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expander Module
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all expander-related types and functions.
|
|
5
|
+
*/
|
|
6
|
+
// Core expander
|
|
7
|
+
export { expandPlan, expandWorkout, validateWorkoutRefs } from "./expander.js";
|
|
8
|
+
// Zone calculations
|
|
9
|
+
export { calculateHRZones, calculatePaceZones, calculateAthleteZones, parsePace, formatPace, getHRZoneForValue, getPaceZoneForValue, } from "./zones.js";
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expander Types
|
|
3
|
+
*
|
|
4
|
+
* Types for the plan expansion process - converting compact plans to expanded format.
|
|
5
|
+
*/
|
|
6
|
+
import type { Sport } from "../schema/compact-plan.js";
|
|
7
|
+
/**
|
|
8
|
+
* A fully expanded workout with all variables interpolated.
|
|
9
|
+
*/
|
|
10
|
+
export interface ExpandedWorkout {
|
|
11
|
+
id: string;
|
|
12
|
+
sport: Sport;
|
|
13
|
+
type: string;
|
|
14
|
+
name: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
durationMinutes?: number;
|
|
17
|
+
primaryZone?: string;
|
|
18
|
+
rpe?: string;
|
|
19
|
+
humanReadable: string;
|
|
20
|
+
completed: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A day in the expanded plan.
|
|
24
|
+
*/
|
|
25
|
+
export interface ExpandedDay {
|
|
26
|
+
date: string;
|
|
27
|
+
dayOfWeek: string;
|
|
28
|
+
workouts: ExpandedWorkout[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Summary statistics for a training week.
|
|
32
|
+
*/
|
|
33
|
+
export interface ExpandedWeekSummary {
|
|
34
|
+
totalHours: number;
|
|
35
|
+
bySport: {
|
|
36
|
+
[sport: string]: {
|
|
37
|
+
sessions: number;
|
|
38
|
+
hours: number;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A week in the expanded plan.
|
|
44
|
+
*/
|
|
45
|
+
export interface ExpandedWeek {
|
|
46
|
+
weekNumber: number;
|
|
47
|
+
startDate: string;
|
|
48
|
+
endDate: string;
|
|
49
|
+
phase: string;
|
|
50
|
+
focus: string;
|
|
51
|
+
targetHours: number;
|
|
52
|
+
days: ExpandedDay[];
|
|
53
|
+
summary: ExpandedWeekSummary;
|
|
54
|
+
isRecoveryWeek: boolean;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A calculated heart rate zone.
|
|
58
|
+
*/
|
|
59
|
+
export interface ExpandedHRZone {
|
|
60
|
+
zone: number;
|
|
61
|
+
name: string;
|
|
62
|
+
percentLow: number;
|
|
63
|
+
percentHigh: number;
|
|
64
|
+
hrLow: number;
|
|
65
|
+
hrHigh: number;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Full heart rate zone configuration.
|
|
69
|
+
*/
|
|
70
|
+
export interface ExpandedHRZones {
|
|
71
|
+
lthr: number;
|
|
72
|
+
maxHR?: number;
|
|
73
|
+
restingHR?: number;
|
|
74
|
+
zones: ExpandedHRZone[];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A calculated pace zone.
|
|
78
|
+
*/
|
|
79
|
+
export interface ExpandedPaceZone {
|
|
80
|
+
zone: string;
|
|
81
|
+
name: string;
|
|
82
|
+
pace: string;
|
|
83
|
+
paceSeconds: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Full pace zone configuration.
|
|
87
|
+
*/
|
|
88
|
+
export interface ExpandedPaceZones {
|
|
89
|
+
thresholdPace: string;
|
|
90
|
+
thresholdPaceSeconds: number;
|
|
91
|
+
zones: ExpandedPaceZone[];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* All athlete zones after calculation.
|
|
95
|
+
*/
|
|
96
|
+
export interface ExpandedAthleteZones {
|
|
97
|
+
run?: {
|
|
98
|
+
hr?: ExpandedHRZones;
|
|
99
|
+
pace?: ExpandedPaceZones;
|
|
100
|
+
};
|
|
101
|
+
bike?: {
|
|
102
|
+
hr?: ExpandedHRZones;
|
|
103
|
+
};
|
|
104
|
+
maxHR?: number;
|
|
105
|
+
restingHR?: number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A training phase in the expanded plan.
|
|
109
|
+
*/
|
|
110
|
+
export interface ExpandedPhase {
|
|
111
|
+
name: string;
|
|
112
|
+
startWeek: number;
|
|
113
|
+
endWeek: number;
|
|
114
|
+
focus: string;
|
|
115
|
+
weeklyHoursRange: {
|
|
116
|
+
low: number;
|
|
117
|
+
high: number;
|
|
118
|
+
};
|
|
119
|
+
keyWorkouts: string[];
|
|
120
|
+
physiologicalGoals: string[];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Metadata for the expanded plan.
|
|
124
|
+
*/
|
|
125
|
+
export interface ExpandedPlanMeta {
|
|
126
|
+
id: string;
|
|
127
|
+
athlete: string;
|
|
128
|
+
event: string;
|
|
129
|
+
eventDate: string;
|
|
130
|
+
planStartDate: string;
|
|
131
|
+
planEndDate: string;
|
|
132
|
+
createdAt: string;
|
|
133
|
+
updatedAt: string;
|
|
134
|
+
totalWeeks: number;
|
|
135
|
+
generatedBy: string;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Unit preferences for the expanded plan.
|
|
139
|
+
*/
|
|
140
|
+
export interface ExpandedUnitPreferences {
|
|
141
|
+
swim: "meters" | "yards";
|
|
142
|
+
bike: "kilometers" | "miles";
|
|
143
|
+
run: "kilometers" | "miles";
|
|
144
|
+
firstDayOfWeek: "monday" | "sunday";
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The complete expanded training plan.
|
|
148
|
+
* This format is consumed by the HTML renderer.
|
|
149
|
+
*/
|
|
150
|
+
export interface ExpandedPlan {
|
|
151
|
+
version: "1.0";
|
|
152
|
+
meta: ExpandedPlanMeta;
|
|
153
|
+
preferences: ExpandedUnitPreferences;
|
|
154
|
+
zones: ExpandedAthleteZones;
|
|
155
|
+
phases: ExpandedPhase[];
|
|
156
|
+
weeks: ExpandedWeek[];
|
|
157
|
+
raceStrategy?: Record<string, unknown>;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Options for the expansion process.
|
|
161
|
+
*/
|
|
162
|
+
export interface ExpansionOptions {
|
|
163
|
+
/** The start date for the plan (defaults to calculating from event date) */
|
|
164
|
+
startDate?: Date;
|
|
165
|
+
/** Whether to validate template references */
|
|
166
|
+
validateTemplates?: boolean;
|
|
167
|
+
/** Whether to include structured workout data (for device export) */
|
|
168
|
+
includeStructure?: boolean;
|
|
169
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zone Calculator
|
|
3
|
+
*
|
|
4
|
+
* Calculates training zone ranges from threshold values.
|
|
5
|
+
* Uses standard physiological percentages for zone calculations.
|
|
6
|
+
*/
|
|
7
|
+
import type { HRZoneConfig, AthletePaces } from "../schema/compact-plan.js";
|
|
8
|
+
import type { ExpandedHRZones, ExpandedHRZone, ExpandedPaceZones, ExpandedPaceZone, ExpandedAthleteZones } from "./types.js";
|
|
9
|
+
/**
|
|
10
|
+
* Calculate heart rate zones from LTHR.
|
|
11
|
+
*
|
|
12
|
+
* Uses the standard 5-zone model with percentages of LTHR.
|
|
13
|
+
*/
|
|
14
|
+
export declare function calculateHRZones(config: HRZoneConfig): ExpandedHRZones;
|
|
15
|
+
/**
|
|
16
|
+
* Parse a pace string into seconds per unit.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* parsePace("5:30/km") // { seconds: 330, unit: "km" }
|
|
20
|
+
* parsePace("8:00/mi") // { seconds: 480, unit: "mi" }
|
|
21
|
+
* parsePace("5:30") // { seconds: 330, unit: undefined }
|
|
22
|
+
*/
|
|
23
|
+
export declare function parsePace(pace: string): {
|
|
24
|
+
seconds: number;
|
|
25
|
+
unit?: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Format seconds back to a pace string.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* formatPace(330, "km") // "5:30/km"
|
|
32
|
+
* formatPace(480) // "8:00"
|
|
33
|
+
*/
|
|
34
|
+
export declare function formatPace(seconds: number, unit?: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Calculate pace zones from threshold pace.
|
|
37
|
+
*/
|
|
38
|
+
export declare function calculatePaceZones(thresholdPace: string): ExpandedPaceZones;
|
|
39
|
+
/**
|
|
40
|
+
* Calculate all athlete zones from compact plan inputs.
|
|
41
|
+
*/
|
|
42
|
+
export declare function calculateAthleteZones(hrConfig?: HRZoneConfig, paces?: AthletePaces): ExpandedAthleteZones;
|
|
43
|
+
/**
|
|
44
|
+
* Get the HR zone for a given heart rate.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getHRZoneForValue(hr: number, zones: ExpandedHRZones): ExpandedHRZone | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Get the pace zone for a given pace.
|
|
49
|
+
*/
|
|
50
|
+
export declare function getPaceZoneForValue(paceSeconds: number, zones: ExpandedPaceZones): ExpandedPaceZone | undefined;
|