Compare commits
No commits in common. "master" and "v0.2.0" have entirely different histories.
|
|
@ -18,7 +18,7 @@ Editing .zwo files by hand is also inconvenient:
|
||||||
|
|
||||||
- you'll have to constantly convert minutes to seconds,
|
- you'll have to constantly convert minutes to seconds,
|
||||||
- you can easily make errors in XML syntax, rendering the file invalid,
|
- you can easily make errors in XML syntax, rendering the file invalid,
|
||||||
- [it's really a bad format.][zwo-sucks]
|
- The syntax is quite inconsistent, making it hard to memoize.
|
||||||
|
|
||||||
There are a few alternative editors online:
|
There are a few alternative editors online:
|
||||||
|
|
||||||
|
|
@ -115,11 +115,8 @@ console.log(stats(workout));
|
||||||
- Repeats (and nested repeats)
|
- Repeats (and nested repeats)
|
||||||
- Unsupported params: message duration & y-position
|
- Unsupported params: message duration & y-position
|
||||||
- More restricted syntax for text (with quotes)
|
- More restricted syntax for text (with quotes)
|
||||||
- Concatenate similar intervals
|
|
||||||
- Distinguish between terrain-sensitive and insensitive free-ride.
|
|
||||||
|
|
||||||
[zwift]: https://zwift.com/
|
[zwift]: https://zwift.com/
|
||||||
[zwofactory]: https://zwofactory.com/
|
[zwofactory]: https://zwofactory.com/
|
||||||
[simple zwo creator]: https://zwifthacks.com/app/simple-zwo-creator/
|
[simple zwo creator]: https://zwifthacks.com/app/simple-zwo-creator/
|
||||||
[workout-editor]: https://nene.github.io/workout-editor/
|
[workout-editor]: https://nene.github.io/workout-editor/
|
||||||
[zwo-sucks]: http://nene.github.io/2021/01/14/zwo-sucks
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ Description:
|
||||||
you will be scored on the final 20 minute segment.
|
you will be scored on the final 20 minute segment.
|
||||||
|
|
||||||
Upon saving your ride, you will be notified if your FTP improved.
|
Upon saving your ride, you will be notified if your FTP improved.
|
||||||
Tags: FTP, Test
|
|
||||||
|
|
||||||
Warmup: 5:00 30%..70%
|
Warmup: 5:00 30%..70%
|
||||||
@ 00:20 Welcome to the FTP test
|
@ 00:20 Welcome to the FTP test
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ Description:
|
||||||
Named after the number 90 in Danish, this workout is focused sweet spot training, centered around 90% of FTP.
|
Named after the number 90 in Danish, this workout is focused sweet spot training, centered around 90% of FTP.
|
||||||
This pairs with Devedeset (90 in Croatian) and Novanta (90 in Italian) to make up the sweet spot trifecta in this plan.
|
This pairs with Devedeset (90 in Croatian) and Novanta (90 in Italian) to make up the sweet spot trifecta in this plan.
|
||||||
The workouts are alphabetically ordered from easiest to hardest, so enjoy the middle of these three challenging workouts today...
|
The workouts are alphabetically ordered from easiest to hardest, so enjoy the middle of these three challenging workouts today...
|
||||||
Tags: Intervals
|
|
||||||
|
|
||||||
Warmup: 7:00 25%..75%
|
Warmup: 7:00 25%..75%
|
||||||
Interval: 00:30 95rpm 95%
|
Interval: 00:30 95rpm 95%
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
Name: Ramps
|
|
||||||
Author: R.Saarsoo
|
|
||||||
Description:
|
|
||||||
Various kinds of ramp intervals.
|
|
||||||
|
|
||||||
Ramp: 5:00 40%..75%
|
|
||||||
|
|
||||||
Ramp: 10:00 80%..90%
|
|
||||||
Ramp: 10:00 90%..80%
|
|
||||||
|
|
||||||
Warmup: 10:00 80%..90%
|
|
||||||
Cooldown: 10:00 90%..80%
|
|
||||||
|
|
||||||
Ramp: 5:00 75%..40%
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
Name: Threshold Dev
|
|
||||||
Description: From 10-12WK FTP Builder
|
|
||||||
Author: Zwift
|
|
||||||
|
|
||||||
Warmup: 10:00 50%..65%
|
|
||||||
Warmup: 5:00 50%..100%
|
|
||||||
Interval: 2:00 65%
|
|
||||||
Interval: 2:00 81%
|
|
||||||
Interval: 1:00 95%
|
|
||||||
|
|
||||||
Rest: 5:00 50%
|
|
||||||
|
|
||||||
Interval: 12:00 81%
|
|
||||||
Rest: 8:00 50%
|
|
||||||
Interval: 12:00 81%
|
|
||||||
Rest: 8:00 50%
|
|
||||||
|
|
||||||
Interval: 5:00 95%
|
|
||||||
Rest: 2:00 50%
|
|
||||||
Interval: 5:00 95%
|
|
||||||
Rest: 2:00 50%
|
|
||||||
Interval: 5:00 95%
|
|
||||||
Rest: 2:00 50%
|
|
||||||
Interval: 5:00 95%
|
|
||||||
Rest: 2:00 50%
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "zwiftout",
|
"name": "zwiftout",
|
||||||
"version": "2.3.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"description": "Zwift workout generator command line tool and library",
|
"description": "Zwift workout generator command line tool and library",
|
||||||
"author": "Rene Saarsoo <github@triin.net>",
|
"author": "Rene Saarsoo <github@triin.net>",
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"zwiftout": "bin/zwiftout.js"
|
"zwiftout": "./bin/zwiftout.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1",
|
"argparse": "^2.0.1",
|
||||||
|
|
|
||||||
|
|
@ -47,38 +47,17 @@ export class RangeIntensity implements Intensity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RangeIntensityEnd implements Intensity {
|
|
||||||
constructor(private _end: number) {}
|
|
||||||
|
|
||||||
get value() {
|
|
||||||
return this._end;
|
|
||||||
}
|
|
||||||
|
|
||||||
get start(): number {
|
|
||||||
throw new Error("RangeIntensityEnd has no start");
|
|
||||||
}
|
|
||||||
|
|
||||||
get end() {
|
|
||||||
return this._end;
|
|
||||||
}
|
|
||||||
|
|
||||||
get zone() {
|
|
||||||
return intensityValueToZoneType(this.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FreeIntensity implements Intensity {
|
export class FreeIntensity implements Intensity {
|
||||||
get value() {
|
get value() {
|
||||||
// To match Zwift, which gives 64 TSS for 1h of freeride.
|
return 0;
|
||||||
return 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get start() {
|
get start() {
|
||||||
return this.value;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get end() {
|
get end() {
|
||||||
return this.value;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get zone() {
|
get zone() {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { IntervalType, SourceLocation } from "./parser/tokenizer";
|
import { IntervalType } from "./parser/tokenizer";
|
||||||
import { Duration } from "./Duration";
|
import { Duration } from "./Duration";
|
||||||
import { Intensity } from "./Intensity";
|
import { Intensity } from "./Intensity";
|
||||||
|
|
||||||
|
|
@ -21,5 +21,4 @@ export type Interval = {
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
offset: Duration;
|
offset: Duration;
|
||||||
text: string;
|
text: string;
|
||||||
loc: SourceLocation;
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -176,9 +176,9 @@ describe("detectRepeats()", () => {
|
||||||
duration: new Duration(100),
|
duration: new Duration(100),
|
||||||
intensity: new ConstantIntensity(1),
|
intensity: new ConstantIntensity(1),
|
||||||
comments: [
|
comments: [
|
||||||
{ offset: new Duration(0), text: "Let's start", loc: { row: 1, col: 1 } },
|
{ offset: new Duration(0), text: "Let's start" },
|
||||||
{ offset: new Duration(20), text: "Stay strong!", loc: { row: 2, col: 1 } },
|
{ offset: new Duration(20), text: "Stay strong!" },
|
||||||
{ offset: new Duration(90), text: "Finish it!", loc: { row: 3, col: 1 } },
|
{ offset: new Duration(90), text: "Finish it!" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -186,8 +186,8 @@ describe("detectRepeats()", () => {
|
||||||
duration: new Duration(100),
|
duration: new Duration(100),
|
||||||
intensity: new ConstantIntensity(0.5),
|
intensity: new ConstantIntensity(0.5),
|
||||||
comments: [
|
comments: [
|
||||||
{ offset: new Duration(0), text: "Huh... have a rest", loc: { row: 4, col: 1 } },
|
{ offset: new Duration(0), text: "Huh... have a rest" },
|
||||||
{ offset: new Duration(80), text: "Ready for next?", loc: { row: 5, col: 1 } },
|
{ offset: new Duration(80), text: "Ready for next?" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -195,9 +195,9 @@ describe("detectRepeats()", () => {
|
||||||
duration: new Duration(100),
|
duration: new Duration(100),
|
||||||
intensity: new ConstantIntensity(1),
|
intensity: new ConstantIntensity(1),
|
||||||
comments: [
|
comments: [
|
||||||
{ offset: new Duration(0), text: "Bring it on again!", loc: { row: 6, col: 1 } },
|
{ offset: new Duration(0), text: "Bring it on again!" },
|
||||||
{ offset: new Duration(50), text: "Half way", loc: { row: 7, col: 1 } },
|
{ offset: new Duration(50), text: "Half way" },
|
||||||
{ offset: new Duration(90), text: "Almost there!", loc: { row: 8, col: 1 } },
|
{ offset: new Duration(90), text: "Almost there!" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -205,9 +205,9 @@ describe("detectRepeats()", () => {
|
||||||
duration: new Duration(100),
|
duration: new Duration(100),
|
||||||
intensity: new ConstantIntensity(0.5),
|
intensity: new ConstantIntensity(0.5),
|
||||||
comments: [
|
comments: [
|
||||||
{ offset: new Duration(30), text: "Wow... you did it!", loc: { row: 9, col: 1 } },
|
{ offset: new Duration(30), text: "Wow... you did it!" },
|
||||||
{ offset: new Duration(40), text: "Nice job.", loc: { row: 10, col: 1 } },
|
{ offset: new Duration(40), text: "Nice job." },
|
||||||
{ offset: new Duration(50), text: "Until next time...", loc: { row: 11, col: 1 } },
|
{ offset: new Duration(50), text: "Until next time..." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -220,20 +220,20 @@ describe("detectRepeats()", () => {
|
||||||
{ type: "Rest", duration: new Duration(100), intensity: new ConstantIntensity(0.5), comments: [] },
|
{ type: "Rest", duration: new Duration(100), intensity: new ConstantIntensity(0.5), comments: [] },
|
||||||
],
|
],
|
||||||
comments: [
|
comments: [
|
||||||
{ offset: new Duration(0), text: "Let's start", loc: { row: 1, col: 1 } },
|
{ offset: new Duration(0), text: "Let's start" },
|
||||||
{ offset: new Duration(20), text: "Stay strong!", loc: { row: 2, col: 1 } },
|
{ offset: new Duration(20), text: "Stay strong!" },
|
||||||
{ offset: new Duration(90), text: "Finish it!", loc: { row: 3, col: 1 } },
|
{ offset: new Duration(90), text: "Finish it!" },
|
||||||
|
|
||||||
{ offset: new Duration(100), text: "Huh... have a rest", loc: { row: 4, col: 1 } },
|
{ offset: new Duration(100), text: "Huh... have a rest" },
|
||||||
{ offset: new Duration(180), text: "Ready for next?", loc: { row: 5, col: 1 } },
|
{ offset: new Duration(180), text: "Ready for next?" },
|
||||||
|
|
||||||
{ offset: new Duration(200), text: "Bring it on again!", loc: { row: 6, col: 1 } },
|
{ offset: new Duration(200), text: "Bring it on again!" },
|
||||||
{ offset: new Duration(250), text: "Half way", loc: { row: 7, col: 1 } },
|
{ offset: new Duration(250), text: "Half way" },
|
||||||
{ offset: new Duration(290), text: "Almost there!", loc: { row: 8, col: 1 } },
|
{ offset: new Duration(290), text: "Almost there!" },
|
||||||
|
|
||||||
{ offset: new Duration(330), text: "Wow... you did it!", loc: { row: 9, col: 1 } },
|
{ offset: new Duration(330), text: "Wow... you did it!" },
|
||||||
{ offset: new Duration(340), text: "Nice job.", loc: { row: 10, col: 1 } },
|
{ offset: new Duration(340), text: "Nice job." },
|
||||||
{ offset: new Duration(350), text: "Until next time...", loc: { row: 11, col: 1 } },
|
{ offset: new Duration(350), text: "Until next time..." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const generateTextEvents = (comments: Comment[]): xml.XmlObject[] => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateRangeInterval = (
|
const generateRangeInterval = (
|
||||||
tagName: "Warmup" | "Cooldown" | "Ramp",
|
tagName: "Warmup" | "Cooldown",
|
||||||
{ duration, intensity, cadence, comments }: Interval,
|
{ duration, intensity, cadence, comments }: Interval,
|
||||||
): xml.XmlObject => {
|
): xml.XmlObject => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -79,22 +79,16 @@ const generateRepeatInterval = (repInterval: RepeatedInterval): xml.XmlObject =>
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateInterval = (
|
const generateInterval = (interval: Interval | RepeatedInterval): xml.XmlObject => {
|
||||||
interval: Interval | RepeatedInterval,
|
|
||||||
index: number,
|
|
||||||
allIntervals: (Interval | RepeatedInterval)[],
|
|
||||||
): xml.XmlObject => {
|
|
||||||
if (interval.type === "repeat") {
|
if (interval.type === "repeat") {
|
||||||
return generateRepeatInterval(interval);
|
return generateRepeatInterval(interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { intensity } = interval;
|
const { intensity } = interval;
|
||||||
if (index === 0 && intensity.start < intensity.end) {
|
if (intensity.start < intensity.end) {
|
||||||
return generateRangeInterval("Warmup", interval);
|
return generateRangeInterval("Warmup", interval);
|
||||||
} else if (index === allIntervals.length - 1 && intensity.start > intensity.end) {
|
} else if (intensity.start > intensity.end) {
|
||||||
return generateRangeInterval("Cooldown", interval);
|
return generateRangeInterval("Cooldown", interval);
|
||||||
} else if (intensity.start !== intensity.end) {
|
|
||||||
return generateRangeInterval("Ramp", interval);
|
|
||||||
} else if (intensity.zone === "free") {
|
} else if (intensity.zone === "free") {
|
||||||
return generateFreeRideInterval(interval);
|
return generateFreeRideInterval(interval);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -102,20 +96,13 @@ const generateInterval = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateTag = (name: string): xml.XmlObject => {
|
export const generateZwo = ({ name, author, description, intervals }: Workout): string => {
|
||||||
return {
|
|
||||||
tag: [{ _attr: { name } }],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateZwo = ({ name, author, description, tags, intervals }: Workout): string => {
|
|
||||||
return xml(
|
return xml(
|
||||||
{
|
{
|
||||||
workout_file: [
|
workout_file: [
|
||||||
{ name: name },
|
{ name: name },
|
||||||
{ author: author },
|
{ author: author },
|
||||||
{ description: description },
|
{ description: description },
|
||||||
{ tags: tags.map(generateTag) },
|
|
||||||
{ sportType: "bike" },
|
{ sportType: "bike" },
|
||||||
{ workout: detectRepeats(intervals).map(generateInterval) },
|
{ workout: detectRepeats(intervals).map(generateInterval) },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,9 @@ export { parseCliOptions } from "./parseCliOptions";
|
||||||
export { Workout, Interval, Comment } from "./ast";
|
export { Workout, Interval, Comment } from "./ast";
|
||||||
export { Duration } from "./Duration";
|
export { Duration } from "./Duration";
|
||||||
export { Intensity, ConstantIntensity, RangeIntensity, FreeIntensity } from "./Intensity";
|
export { Intensity, ConstantIntensity, RangeIntensity, FreeIntensity } from "./Intensity";
|
||||||
export { ZoneType, intensityValueToZoneType } from "./ZoneType";
|
export { ZoneType } from "./ZoneType";
|
||||||
export { SourceLocation } from "./parser/tokenizer";
|
|
||||||
import { ParseError } from "./parser/ParseError";
|
|
||||||
import { ValidationError } from "./parser/ValidationError";
|
|
||||||
export type ZwiftoutException = ParseError | ValidationError;
|
|
||||||
export { ParseError, ValidationError };
|
|
||||||
|
|
||||||
// utils
|
// utils
|
||||||
export { totalDuration } from "./stats/totalDuration";
|
export { totalDuration } from "./stats/totalDuration";
|
||||||
export { maximumIntensity } from "./stats/maximumIntensity";
|
export { maximumIntensity } from "./stats/maximumIntensity";
|
||||||
|
export { chunkRangeIntervals } from "./utils/chunkRangeIntervals";
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,13 @@ export const parseCliOptions = (): CliOptions => {
|
||||||
default: false,
|
default: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
argParser.add_argument("file", {
|
argParser.add_argument("file", { nargs: 1 });
|
||||||
nargs: "?",
|
|
||||||
default: 0, // Default to reading STDIN
|
|
||||||
});
|
|
||||||
|
|
||||||
return argParser.parse_args();
|
// As we only allow one file as input,
|
||||||
|
// convert filenames array to just a single string.
|
||||||
|
const { file, ...rest } = argParser.parse_args();
|
||||||
|
return {
|
||||||
|
file: file[0],
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { SourceLocation } from "./tokenizer";
|
import { SourceLocation } from "./tokenizer";
|
||||||
|
|
||||||
export class ParseError extends Error {
|
export class ParseError extends Error {
|
||||||
public loc: SourceLocation;
|
constructor(msg: string, { row, col }: SourceLocation) {
|
||||||
constructor(msg: string, loc: SourceLocation) {
|
super(`${msg} at line ${row + 1} char ${col + 1}`);
|
||||||
super(`${msg} at line ${loc.row + 1} char ${loc.col + 1}`);
|
|
||||||
this.loc = loc;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { SourceLocation } from "./tokenizer";
|
|
||||||
|
|
||||||
export class ValidationError extends Error {
|
|
||||||
public loc: SourceLocation;
|
|
||||||
constructor(msg: string, loc: SourceLocation) {
|
|
||||||
super(`${msg} at line ${loc.row + 1} char ${loc.col + 1}`);
|
|
||||||
this.loc = loc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { Interval, Workout } from "../ast";
|
|
||||||
import { FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
|
|
||||||
|
|
||||||
const fillIntensities = (prevInterval: Interval, interval: Interval): Interval => {
|
|
||||||
if (!(interval.intensity instanceof RangeIntensityEnd)) {
|
|
||||||
return interval;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevInterval.intensity instanceof FreeIntensity) {
|
|
||||||
throw new Error("range-intensity-end interval can't be after free-intensity interval");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...interval,
|
|
||||||
intensity: new RangeIntensity(prevInterval.intensity.end, interval.intensity.end),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Given: [1, 2, 3, 4]
|
|
||||||
// Returns: [[1,2], [2,3], [3,4]]
|
|
||||||
const pairs = <T>(arr: T[]): T[][] => {
|
|
||||||
const result: T[][] = [];
|
|
||||||
for (let i = 1; i < arr.length; i++) {
|
|
||||||
result.push([arr[i - 1], arr[i]]);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fillIntensitiesInIntervals = (intervals: Interval[]): Interval[] => {
|
|
||||||
if (intervals.length <= 1) {
|
|
||||||
if (intervals.length === 1 && intervals[0].intensity instanceof RangeIntensityEnd) {
|
|
||||||
throw new Error("range-intensity-end interval can't be the first interval");
|
|
||||||
}
|
|
||||||
return intervals;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [intervals[0], ...pairs(intervals).map(([prev, curr]) => fillIntensities(prev, curr))];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fillRangeIntensities = (workout: Workout): Workout => {
|
|
||||||
return {
|
|
||||||
...workout,
|
|
||||||
intervals: fillIntensitiesInIntervals(workout.intervals),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { Workout } from "../ast";
|
import { Workout } from "../ast";
|
||||||
import { fillRangeIntensities } from "./fillRangeIntensities";
|
|
||||||
import { parseTokens } from "./parser";
|
import { parseTokens } from "./parser";
|
||||||
import { tokenize } from "./tokenizer";
|
import { tokenize } from "./tokenizer";
|
||||||
import { validate } from "./validate";
|
|
||||||
|
|
||||||
export const parse = (source: string): Workout => validate(fillRangeIntensities(parseTokens(tokenize(source))));
|
export const parse = (source: string): Workout => parseTokens(tokenize(source));
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,6 @@ Name: My Workout
|
||||||
|
|
||||||
Warmup: 5:30 50%..80% 100rpm
|
Warmup: 5:30 50%..80% 100rpm
|
||||||
Cooldown: 5:30 70%..45%
|
Cooldown: 5:30 70%..45%
|
||||||
Ramp: 5:30 90%..100%
|
|
||||||
`).intervals,
|
`).intervals,
|
||||||
).toMatchInlineSnapshot(`
|
).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
|
|
@ -259,147 +258,10 @@ Ramp: 5:30 90%..100%
|
||||||
},
|
},
|
||||||
"type": "Cooldown",
|
"type": "Cooldown",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 330,
|
|
||||||
},
|
|
||||||
"intensity": RangeIntensity {
|
|
||||||
"_end": 1,
|
|
||||||
"_start": 0.9,
|
|
||||||
},
|
|
||||||
"type": "Ramp",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses end-of-range intervals after power-range interval", () => {
|
|
||||||
expect(
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
|
|
||||||
Ramp: 5:00 50%..75%
|
|
||||||
Ramp: 5:00 ..100%
|
|
||||||
Ramp: 5:00 ..50%
|
|
||||||
`).intervals,
|
|
||||||
).toMatchInlineSnapshot(`
|
|
||||||
Array [
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 300,
|
|
||||||
},
|
|
||||||
"intensity": RangeIntensity {
|
|
||||||
"_end": 0.75,
|
|
||||||
"_start": 0.5,
|
|
||||||
},
|
|
||||||
"type": "Ramp",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 300,
|
|
||||||
},
|
|
||||||
"intensity": RangeIntensity {
|
|
||||||
"_end": 1,
|
|
||||||
"_start": 0.75,
|
|
||||||
},
|
|
||||||
"type": "Ramp",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 300,
|
|
||||||
},
|
|
||||||
"intensity": RangeIntensity {
|
|
||||||
"_end": 0.5,
|
|
||||||
"_start": 1,
|
|
||||||
},
|
|
||||||
"type": "Ramp",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses end-of-range intervals after normal interval", () => {
|
|
||||||
expect(
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
|
|
||||||
Interval: 5:00 75%
|
|
||||||
Ramp: 5:00 ..100%
|
|
||||||
Ramp: 5:00 ..50%
|
|
||||||
`).intervals,
|
|
||||||
).toMatchInlineSnapshot(`
|
|
||||||
Array [
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 300,
|
|
||||||
},
|
|
||||||
"intensity": ConstantIntensity {
|
|
||||||
"_value": 0.75,
|
|
||||||
},
|
|
||||||
"type": "Interval",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 300,
|
|
||||||
},
|
|
||||||
"intensity": RangeIntensity {
|
|
||||||
"_end": 1,
|
|
||||||
"_start": 0.75,
|
|
||||||
},
|
|
||||||
"type": "Ramp",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 300,
|
|
||||||
},
|
|
||||||
"intensity": RangeIntensity {
|
|
||||||
"_end": 0.5,
|
|
||||||
"_start": 1,
|
|
||||||
},
|
|
||||||
"type": "Ramp",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error when end-of-range intervals after free-ride interval", () => {
|
|
||||||
expect(
|
|
||||||
() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
|
|
||||||
FreeRide: 5:00
|
|
||||||
Ramp: 5:00 ..100%
|
|
||||||
Ramp: 5:00 ..50%
|
|
||||||
`).intervals,
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be after free-intensity interval"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error when end-of-range intervals is the first interval", () => {
|
|
||||||
expect(
|
|
||||||
() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
|
|
||||||
Ramp: 5:00 ..50%
|
|
||||||
`).intervals,
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be the first interval"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses free-ride intervals", () => {
|
it("parses free-ride intervals", () => {
|
||||||
expect(
|
expect(
|
||||||
parse(`
|
parse(`
|
||||||
|
|
@ -596,50 +458,30 @@ Rest: 5:00 50%
|
||||||
"cadence": undefined,
|
"cadence": undefined,
|
||||||
"comments": Array [
|
"comments": Array [
|
||||||
Object {
|
Object {
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 3,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
"offset": Duration {
|
||||||
"seconds": 0,
|
"seconds": 0,
|
||||||
},
|
},
|
||||||
"text": "Find your rythm.",
|
"text": "Find your rythm.",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 4,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
"offset": Duration {
|
||||||
"seconds": 60,
|
"seconds": 60,
|
||||||
},
|
},
|
||||||
"text": "Try to settle in for the effort",
|
"text": "Try to settle in for the effort",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 6,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
"offset": Duration {
|
||||||
"seconds": 300,
|
"seconds": 300,
|
||||||
},
|
},
|
||||||
"text": "Half way through",
|
"text": "Half way through",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 8,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
"offset": Duration {
|
||||||
"seconds": 540,
|
"seconds": 540,
|
||||||
},
|
},
|
||||||
"text": "Almost there",
|
"text": "Almost there",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 9,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
"offset": Duration {
|
||||||
"seconds": 570,
|
"seconds": 570,
|
||||||
},
|
},
|
||||||
|
|
@ -658,20 +500,12 @@ Rest: 5:00 50%
|
||||||
"cadence": undefined,
|
"cadence": undefined,
|
||||||
"comments": Array [
|
"comments": Array [
|
||||||
Object {
|
Object {
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 12,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
"offset": Duration {
|
||||||
"seconds": 0,
|
"seconds": 0,
|
||||||
},
|
},
|
||||||
"text": "Great effort!",
|
"text": "Great effort!",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 13,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
"offset": Duration {
|
||||||
"seconds": 30,
|
"seconds": 30,
|
||||||
},
|
},
|
||||||
|
|
@ -692,475 +526,4 @@ Rest: 5:00 50%
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses last comment with negative offset", () => {
|
|
||||||
expect(
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 10:00 90%
|
|
||||||
@ 0:10 Find your rythm.
|
|
||||||
@ -0:10 Final push. YOU GOT IT!
|
|
||||||
`),
|
|
||||||
).toMatchInlineSnapshot(`
|
|
||||||
Object {
|
|
||||||
"author": "",
|
|
||||||
"description": "",
|
|
||||||
"intervals": Array [
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 3,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 10,
|
|
||||||
},
|
|
||||||
"text": "Find your rythm.",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 4,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 590,
|
|
||||||
},
|
|
||||||
"text": "Final push. YOU GOT IT!",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 600,
|
|
||||||
},
|
|
||||||
"intensity": ConstantIntensity {
|
|
||||||
"_value": 0.9,
|
|
||||||
},
|
|
||||||
"type": "Interval",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"name": "My Workout",
|
|
||||||
"tags": Array [],
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses comment with negative offset before absolutely offset comment", () => {
|
|
||||||
expect(
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 1:00 90%
|
|
||||||
@ -0:10 Before last
|
|
||||||
@ 0:50 Last!
|
|
||||||
`),
|
|
||||||
).toMatchInlineSnapshot(`
|
|
||||||
Object {
|
|
||||||
"author": "",
|
|
||||||
"description": "",
|
|
||||||
"intervals": Array [
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 3,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 40,
|
|
||||||
},
|
|
||||||
"text": "Before last",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 4,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 50,
|
|
||||||
},
|
|
||||||
"text": "Last!",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 60,
|
|
||||||
},
|
|
||||||
"intensity": ConstantIntensity {
|
|
||||||
"_value": 0.9,
|
|
||||||
},
|
|
||||||
"type": "Interval",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"name": "My Workout",
|
|
||||||
"tags": Array [],
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses multiple comments with negative offsets in row", () => {
|
|
||||||
expect(
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 1:00 90%
|
|
||||||
@ -0:10 One more before last
|
|
||||||
@ -0:10 Before last
|
|
||||||
@ -0:10 Last!
|
|
||||||
`),
|
|
||||||
).toMatchInlineSnapshot(`
|
|
||||||
Object {
|
|
||||||
"author": "",
|
|
||||||
"description": "",
|
|
||||||
"intervals": Array [
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 3,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 30,
|
|
||||||
},
|
|
||||||
"text": "One more before last",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 4,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 40,
|
|
||||||
},
|
|
||||||
"text": "Before last",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 5,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 50,
|
|
||||||
},
|
|
||||||
"text": "Last!",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 60,
|
|
||||||
},
|
|
||||||
"intensity": ConstantIntensity {
|
|
||||||
"_value": 0.9,
|
|
||||||
},
|
|
||||||
"type": "Interval",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"name": "My Workout",
|
|
||||||
"tags": Array [],
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses intervals with positive comment offsets", () => {
|
|
||||||
expect(
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 10:00 90%
|
|
||||||
@ 0:50 First comment
|
|
||||||
@ +0:10 Comment #2 10 seconds later
|
|
||||||
@ +0:10 Comment #3 another 10 seconds later
|
|
||||||
@ 5:00 Half way!
|
|
||||||
@ +0:10 Comment #5 10 seconds later
|
|
||||||
@ +0:10 Comment #6 another 10 seconds later
|
|
||||||
`),
|
|
||||||
).toMatchInlineSnapshot(`
|
|
||||||
Object {
|
|
||||||
"author": "",
|
|
||||||
"description": "",
|
|
||||||
"intervals": Array [
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 3,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 50,
|
|
||||||
},
|
|
||||||
"text": "First comment",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 4,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 60,
|
|
||||||
},
|
|
||||||
"text": "Comment #2 10 seconds later",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 5,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 70,
|
|
||||||
},
|
|
||||||
"text": "Comment #3 another 10 seconds later",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 6,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 300,
|
|
||||||
},
|
|
||||||
"text": "Half way!",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 7,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 310,
|
|
||||||
},
|
|
||||||
"text": "Comment #5 10 seconds later",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 8,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 320,
|
|
||||||
},
|
|
||||||
"text": "Comment #6 another 10 seconds later",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 600,
|
|
||||||
},
|
|
||||||
"intensity": ConstantIntensity {
|
|
||||||
"_value": 0.9,
|
|
||||||
},
|
|
||||||
"type": "Interval",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"name": "My Workout",
|
|
||||||
"tags": Array [],
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats positive comment offset as relative to interval start when there's no previous comment", () => {
|
|
||||||
expect(
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 10:00 90%
|
|
||||||
@ +1:00 First comment
|
|
||||||
`),
|
|
||||||
).toMatchInlineSnapshot(`
|
|
||||||
Object {
|
|
||||||
"author": "",
|
|
||||||
"description": "",
|
|
||||||
"intervals": Array [
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 3,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 60,
|
|
||||||
},
|
|
||||||
"text": "First comment",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 600,
|
|
||||||
},
|
|
||||||
"intensity": ConstantIntensity {
|
|
||||||
"_value": 0.9,
|
|
||||||
},
|
|
||||||
"type": "Interval",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"name": "My Workout",
|
|
||||||
"tags": Array [],
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error when negative offset is followed by positive offset", () => {
|
|
||||||
expect(() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 2:00 90%
|
|
||||||
@ -0:10 Comment 1
|
|
||||||
@ +0:30 Comment 2
|
|
||||||
@ 1:30 Comment 3
|
|
||||||
`),
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(`"Negative offset followed by positive offset at line 5 char 5"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("works fine when positive offset is followed by negative offset", () => {
|
|
||||||
expect(
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 1:00 90%
|
|
||||||
@ +0:10 Comment 1
|
|
||||||
@ -0:10 Comment 2
|
|
||||||
`),
|
|
||||||
).toMatchInlineSnapshot(`
|
|
||||||
Object {
|
|
||||||
"author": "",
|
|
||||||
"description": "",
|
|
||||||
"intervals": Array [
|
|
||||||
Object {
|
|
||||||
"cadence": undefined,
|
|
||||||
"comments": Array [
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 3,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 10,
|
|
||||||
},
|
|
||||||
"text": "Comment 1",
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
"loc": Object {
|
|
||||||
"col": 4,
|
|
||||||
"row": 4,
|
|
||||||
},
|
|
||||||
"offset": Duration {
|
|
||||||
"seconds": 50,
|
|
||||||
},
|
|
||||||
"text": "Comment 2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"duration": Duration {
|
|
||||||
"seconds": 60,
|
|
||||||
},
|
|
||||||
"intensity": ConstantIntensity {
|
|
||||||
"_value": 0.9,
|
|
||||||
},
|
|
||||||
"type": "Interval",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"name": "My Workout",
|
|
||||||
"tags": Array [],
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error when comment offset is outside of interval length", () => {
|
|
||||||
expect(() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 2:00 90%
|
|
||||||
@ 0:00 Find your rythm.
|
|
||||||
@ 3:10 Try to settle in for the effort
|
|
||||||
`),
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(`"Comment offset is larger than interval length at line 5 char 5"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error when negative comment offset is outside of interval", () => {
|
|
||||||
expect(() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 2:00 90%
|
|
||||||
@ 0:00 Find your rythm.
|
|
||||||
@ -3:10 Try to settle in for the effort
|
|
||||||
`),
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(`"Negative comment offset is larger than interval length at line 5 char 5"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error when comment offset is the same as another comment offset", () => {
|
|
||||||
expect(() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 2:00 90%
|
|
||||||
@ 0:00 First comment
|
|
||||||
@ 1:00 Comment
|
|
||||||
@ 1:00 Overlapping comment
|
|
||||||
@ 1:50 Last comment
|
|
||||||
`),
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(`"Comment overlaps previous comment at line 6 char 5"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error when comment offset is greater than next comment offset", () => {
|
|
||||||
expect(() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 2:00 90%
|
|
||||||
@ 0:00 First comment
|
|
||||||
@ 1:20 Comment
|
|
||||||
@ 1:00 Misplaced comment
|
|
||||||
@ 1:50 Last comment
|
|
||||||
`),
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(`"Comment overlaps previous comment at line 6 char 5"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error when comments too close together", () => {
|
|
||||||
expect(() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 2:00 90%
|
|
||||||
@ 0:00 First comment
|
|
||||||
@ 0:01 Second Comment
|
|
||||||
`),
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(`"Less than 10 seconds between comments at line 5 char 5"`);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 2:00 90%
|
|
||||||
@ 0:00 First comment
|
|
||||||
@ 0:09 Second Comment
|
|
||||||
`),
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(`"Less than 10 seconds between comments at line 5 char 5"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("triggers no error when comments at least 10 seconds apart", () => {
|
|
||||||
expect(() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 2:00 90%
|
|
||||||
@ 0:00 First comment
|
|
||||||
@ 0:10 Second Comment
|
|
||||||
`),
|
|
||||||
).not.toThrowError();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error when comment does not finish before end of interval", () => {
|
|
||||||
expect(() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 1:00 80%
|
|
||||||
@ 0:51 First comment
|
|
||||||
Interval: 1:00 90%
|
|
||||||
`),
|
|
||||||
).toThrowErrorMatchingInlineSnapshot(
|
|
||||||
`"Less than 10 seconds between comment start and interval end at line 4 char 5"`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("triggers no error when comment finishes right at interval end", () => {
|
|
||||||
expect(() =>
|
|
||||||
parse(`
|
|
||||||
Name: My Workout
|
|
||||||
Interval: 1:00 80%
|
|
||||||
@ 0:50 First comment
|
|
||||||
Interval: 1:00 90%
|
|
||||||
`),
|
|
||||||
).not.toThrowError();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { last } from "ramda";
|
|
||||||
import { Interval, Workout, Comment } from "../ast";
|
import { Interval, Workout, Comment } from "../ast";
|
||||||
import { Duration } from "../Duration";
|
import { Duration } from "../Duration";
|
||||||
import { ConstantIntensity, FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
|
import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity";
|
||||||
import { ParseError } from "./ParseError";
|
import { ParseError } from "./ParseError";
|
||||||
import { IntervalType, OffsetToken, SourceLocation, Token } from "./tokenizer";
|
import { IntervalType, SourceLocation, Token } from "./tokenizer";
|
||||||
|
|
||||||
type Header = Partial<Omit<Workout, "intervals">>;
|
type Header = Partial<Omit<Workout, "intervals">>;
|
||||||
|
|
||||||
|
|
@ -58,18 +57,12 @@ const parseHeader = (tokens: Token[]): [Header, Token[]] => {
|
||||||
return [header, tokens];
|
return [header, tokens];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartialComment = {
|
const parseIntervalComments = (tokens: Token[]): [Comment[], Token[]] => {
|
||||||
offsetToken: OffsetToken;
|
const comments: Comment[] = [];
|
||||||
text: string;
|
|
||||||
loc: SourceLocation;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Comment[], Token[]] => {
|
|
||||||
const comments: PartialComment[] = [];
|
|
||||||
while (tokens[0]) {
|
while (tokens[0]) {
|
||||||
const [start, offset, text, ...rest] = tokens;
|
const [start, offset, text, ...rest] = tokens;
|
||||||
if (start.type === "comment-start") {
|
if (start.type === "comment-start") {
|
||||||
if (!offset || offset.type !== "offset") {
|
if (!offset || offset.type !== "duration") {
|
||||||
throw new ParseError(
|
throw new ParseError(
|
||||||
`Expected [comment offset] instead got ${tokenToString(offset)}`,
|
`Expected [comment offset] instead got ${tokenToString(offset)}`,
|
||||||
offset?.loc || start.loc,
|
offset?.loc || start.loc,
|
||||||
|
|
@ -79,64 +72,15 @@ const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Co
|
||||||
throw new ParseError(`Expected [comment text] instead got ${tokenToString(text)}`, text?.loc || offset.loc);
|
throw new ParseError(`Expected [comment text] instead got ${tokenToString(text)}`, text?.loc || offset.loc);
|
||||||
}
|
}
|
||||||
comments.push({
|
comments.push({
|
||||||
offsetToken: offset,
|
offset: new Duration(offset.value),
|
||||||
text: text.value,
|
text: text.value,
|
||||||
loc: offset.loc,
|
|
||||||
});
|
});
|
||||||
tokens = rest;
|
tokens = rest;
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return [comments, tokens];
|
||||||
return [computeAbsoluteOffsets(comments, intervalDuration), tokens];
|
|
||||||
};
|
|
||||||
|
|
||||||
const computeAbsoluteOffsets = (partialComments: PartialComment[], intervalDuration: Duration): Comment[] => {
|
|
||||||
const comments: Comment[] = [];
|
|
||||||
for (let i = 0; i < partialComments.length; i++) {
|
|
||||||
const pComment = partialComments[i];
|
|
||||||
const offsetToken = pComment.offsetToken;
|
|
||||||
|
|
||||||
// Assume absolute offset by default
|
|
||||||
let offset: Duration = new Duration(offsetToken.value);
|
|
||||||
|
|
||||||
if (offsetToken.kind === "relative-plus") {
|
|
||||||
// Position relative to previous already-computed comment offset
|
|
||||||
const previousComment = last(comments);
|
|
||||||
if (previousComment) {
|
|
||||||
offset = new Duration(previousComment.offset.seconds + offset.seconds);
|
|
||||||
}
|
|
||||||
} else if (offsetToken.kind === "relative-minus") {
|
|
||||||
// Position relative to next comment or interval end
|
|
||||||
offset = new Duration(nextCommentOffset(partialComments, i, intervalDuration).seconds - offset.seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
comments.push({
|
|
||||||
offset,
|
|
||||||
loc: pComment.loc,
|
|
||||||
text: pComment.text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return comments;
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextCommentOffset = (partialComments: PartialComment[], i: number, intervalDuration: Duration): Duration => {
|
|
||||||
const nextComment = partialComments[i + 1];
|
|
||||||
if (!nextComment) {
|
|
||||||
return intervalDuration;
|
|
||||||
}
|
|
||||||
switch (nextComment.offsetToken.kind) {
|
|
||||||
case "relative-minus":
|
|
||||||
return new Duration(
|
|
||||||
nextCommentOffset(partialComments, i + 1, intervalDuration).seconds - nextComment.offsetToken.value,
|
|
||||||
);
|
|
||||||
case "relative-plus":
|
|
||||||
throw new ParseError("Negative offset followed by positive offset", nextComment.offsetToken.loc);
|
|
||||||
case "absolute":
|
|
||||||
default:
|
|
||||||
return new Duration(nextComment.offsetToken.value);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLocation): [Interval, Token[]] => {
|
const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLocation): [Interval, Token[]] => {
|
||||||
|
|
@ -158,9 +102,6 @@ const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLoc
|
||||||
} else if (token.type === "intensity-range") {
|
} else if (token.type === "intensity-range") {
|
||||||
intensity = new RangeIntensity(token.value[0], token.value[1]);
|
intensity = new RangeIntensity(token.value[0], token.value[1]);
|
||||||
tokens.shift();
|
tokens.shift();
|
||||||
} else if (token.type === "intensity-range-end") {
|
|
||||||
intensity = new RangeIntensityEnd(token.value);
|
|
||||||
tokens.shift();
|
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +114,7 @@ const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLoc
|
||||||
intensity = new FreeIntensity();
|
intensity = new FreeIntensity();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [comments, rest] = parseIntervalComments(tokens, duration);
|
const [comments, rest] = parseIntervalComments(tokens);
|
||||||
|
|
||||||
return [{ type, duration, intensity, cadence, comments }, rest];
|
return [{ type, duration, intensity, cadence, comments }, rest];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { ParseError } from "./ParseError";
|
import { ParseError } from "./ParseError";
|
||||||
|
|
||||||
export type HeaderType = "Name" | "Author" | "Description" | "Tags";
|
export type HeaderType = "Name" | "Author" | "Description" | "Tags";
|
||||||
export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide" | "Ramp";
|
export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide";
|
||||||
|
|
||||||
const isHeaderType = (value: string): value is HeaderType => {
|
const isHeaderType = (value: string): value is HeaderType => {
|
||||||
return ["Name", "Author", "Description", "Tags"].includes(value);
|
return ["Name", "Author", "Description", "Tags"].includes(value);
|
||||||
};
|
};
|
||||||
const isIntervalType = (value: string): value is IntervalType => {
|
const isIntervalType = (value: string): value is IntervalType => {
|
||||||
return ["Warmup", "Rest", "Interval", "Cooldown", "FreeRide", "Ramp"].includes(value);
|
return ["Warmup", "Rest", "Interval", "Cooldown", "FreeRide"].includes(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 0-based row and column indexes. First line is 0th.
|
// 0-based row and column indexes. First line is 0th.
|
||||||
|
|
@ -36,36 +36,17 @@ export type NumberToken = {
|
||||||
value: number;
|
value: number;
|
||||||
loc: SourceLocation;
|
loc: SourceLocation;
|
||||||
};
|
};
|
||||||
export type OffsetToken = {
|
|
||||||
type: "offset";
|
|
||||||
kind: "absolute" | "relative-plus" | "relative-minus";
|
|
||||||
value: number;
|
|
||||||
loc: SourceLocation;
|
|
||||||
};
|
|
||||||
export type RangeIntensityToken = {
|
export type RangeIntensityToken = {
|
||||||
type: "intensity-range";
|
type: "intensity-range";
|
||||||
value: [number, number];
|
value: [number, number];
|
||||||
loc: SourceLocation;
|
loc: SourceLocation;
|
||||||
};
|
};
|
||||||
export type RangeIntensityEndToken = {
|
|
||||||
type: "intensity-range-end";
|
|
||||||
value: number;
|
|
||||||
loc: SourceLocation;
|
|
||||||
};
|
|
||||||
export type CommentStartToken = {
|
export type CommentStartToken = {
|
||||||
type: "comment-start";
|
type: "comment-start";
|
||||||
value?: undefined;
|
value?: undefined;
|
||||||
loc: SourceLocation;
|
loc: SourceLocation;
|
||||||
};
|
};
|
||||||
export type Token =
|
export type Token = HeaderToken | IntervalToken | TextToken | NumberToken | RangeIntensityToken | CommentStartToken;
|
||||||
| HeaderToken
|
|
||||||
| IntervalToken
|
|
||||||
| TextToken
|
|
||||||
| NumberToken
|
|
||||||
| OffsetToken
|
|
||||||
| RangeIntensityToken
|
|
||||||
| RangeIntensityEndToken
|
|
||||||
| CommentStartToken;
|
|
||||||
|
|
||||||
const toInteger = (str: string): number => {
|
const toInteger = (str: string): number => {
|
||||||
return parseInt(str.replace(/[^0-9]/, ""), 10);
|
return parseInt(str.replace(/[^0-9]/, ""), 10);
|
||||||
|
|
@ -87,14 +68,10 @@ const tokenizeValueParam = (text: string, loc: SourceLocation): Token => {
|
||||||
if (/^[0-9]+rpm$/.test(text)) {
|
if (/^[0-9]+rpm$/.test(text)) {
|
||||||
return { type: "cadence", value: toInteger(text), loc };
|
return { type: "cadence", value: toInteger(text), loc };
|
||||||
}
|
}
|
||||||
if (/^[0-9]+%\.\.[0-9]+%$/.test(text)) {
|
if (/^[0-9]+%..[0-9]+%$/.test(text)) {
|
||||||
const [from, to] = text.split("..").map(toInteger).map(toFraction);
|
const [from, to] = text.split("..").map(toInteger).map(toFraction);
|
||||||
return { type: "intensity-range", value: [from, to], loc };
|
return { type: "intensity-range", value: [from, to], loc };
|
||||||
}
|
}
|
||||||
if (/^\.\.[0-9]+%$/.test(text)) {
|
|
||||||
const [, /* _ */ to] = text.split("..").map(toInteger).map(toFraction);
|
|
||||||
return { type: "intensity-range-end", value: to, loc };
|
|
||||||
}
|
|
||||||
if (/^[0-9]+%$/.test(text)) {
|
if (/^[0-9]+%$/.test(text)) {
|
||||||
return { type: "intensity", value: toFraction(toInteger(text)), loc };
|
return { type: "intensity", value: toFraction(toInteger(text)), loc };
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +89,7 @@ const tokenizeParams = (text: string, loc: SourceLocation): Token[] => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const tokenizeComment = (line: string, row: number): Token[] | undefined => {
|
const tokenizeComment = (line: string, row: number): Token[] | undefined => {
|
||||||
const [, commentHead, sign, offset, commentText] = line.match(/^(\s*@\s*)([-+]?)([0-9:]+)(.*?)$/) || [];
|
const [, commentHead, offset, commentText] = line.match(/^(\s*@\s*)([0-9:]+)(.*?)$/) || [];
|
||||||
if (!commentHead) {
|
if (!commentHead) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -121,27 +98,11 @@ const tokenizeComment = (line: string, row: number): Token[] | undefined => {
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ type: "comment-start", loc: { row, col: line.indexOf("@") } },
|
{ type: "comment-start", loc: { row, col: line.indexOf("@") } },
|
||||||
{
|
{ type: "duration", value: toSeconds(offset), loc: { row, col: commentHead.length } },
|
||||||
type: "offset",
|
|
||||||
kind: signToKind(sign),
|
|
||||||
value: toSeconds(offset),
|
|
||||||
loc: { row, col: commentHead.length },
|
|
||||||
},
|
|
||||||
{ type: "text", value: commentText.trim(), loc: { row, col: commentHead.length + offset.length } },
|
{ type: "text", value: commentText.trim(), loc: { row, col: commentHead.length + offset.length } },
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const signToKind = (sign: string) => {
|
|
||||||
switch (sign) {
|
|
||||||
case "-":
|
|
||||||
return "relative-minus";
|
|
||||||
case "+":
|
|
||||||
return "relative-plus";
|
|
||||||
default:
|
|
||||||
return "absolute";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tokenizeHeader = (label: HeaderType, separator: string, paramString: string, row: number): Token[] => {
|
const tokenizeHeader = (label: HeaderType, separator: string, paramString: string, row: number): Token[] => {
|
||||||
const token: HeaderToken = {
|
const token: HeaderToken = {
|
||||||
type: "header",
|
type: "header",
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { Workout, Interval } from "../ast";
|
|
||||||
import { ValidationError } from "./ValidationError";
|
|
||||||
|
|
||||||
const validateCommentOffsets = ({ comments, duration }: Interval) => {
|
|
||||||
for (let i = 0; i < comments.length; i++) {
|
|
||||||
const comment = comments[i];
|
|
||||||
if (comment.offset.seconds >= duration.seconds) {
|
|
||||||
throw new ValidationError(`Comment offset is larger than interval length`, comment.loc);
|
|
||||||
}
|
|
||||||
if (comment.offset.seconds < 0) {
|
|
||||||
throw new ValidationError(`Negative comment offset is larger than interval length`, comment.loc);
|
|
||||||
}
|
|
||||||
if (i > 0 && comment.offset.seconds <= comments[i - 1].offset.seconds) {
|
|
||||||
throw new ValidationError(`Comment overlaps previous comment`, comment.loc);
|
|
||||||
}
|
|
||||||
if (i > 0 && comment.offset.seconds < comments[i - 1].offset.seconds + 10) {
|
|
||||||
throw new ValidationError(`Less than 10 seconds between comments`, comment.loc);
|
|
||||||
}
|
|
||||||
if (comment.offset.seconds + 10 > duration.seconds) {
|
|
||||||
throw new ValidationError(`Less than 10 seconds between comment start and interval end`, comment.loc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const validate = (workout: Workout): Workout => {
|
|
||||||
workout.intervals.forEach(validateCommentOffsets);
|
|
||||||
return workout;
|
|
||||||
};
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { Workout } from "../ast";
|
import { Workout } from "../ast";
|
||||||
import { detectRepeats } from "../detectRepeats";
|
|
||||||
import { Duration } from "../Duration";
|
import { Duration } from "../Duration";
|
||||||
import { Intensity } from "../Intensity";
|
import { Intensity } from "../Intensity";
|
||||||
import { averageIntensity } from "./averageIntensity";
|
import { averageIntensity } from "./averageIntensity";
|
||||||
import { normalizedIntensity } from "./normalizedIntensity";
|
import { normalizedIntensity } from "./normalizedIntensity";
|
||||||
import { totalDuration } from "./totalDuration";
|
import { totalDuration } from "./totalDuration";
|
||||||
import { tss } from "./tss";
|
import { tss } from "./tss";
|
||||||
import { xp } from "./xp";
|
|
||||||
import { zoneDistribution, ZoneDuration } from "./zoneDistribution";
|
import { zoneDistribution, ZoneDuration } from "./zoneDistribution";
|
||||||
|
|
||||||
export type Stats = {
|
export type Stats = {
|
||||||
|
|
@ -14,7 +12,6 @@ export type Stats = {
|
||||||
averageIntensity: Intensity;
|
averageIntensity: Intensity;
|
||||||
normalizedIntensity: Intensity;
|
normalizedIntensity: Intensity;
|
||||||
tss: number;
|
tss: number;
|
||||||
xp: number;
|
|
||||||
zones: ZoneDuration[];
|
zones: ZoneDuration[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -27,12 +24,11 @@ export const stats = ({ intervals }: Workout): Stats => {
|
||||||
averageIntensity: averageIntensity(intervals),
|
averageIntensity: averageIntensity(intervals),
|
||||||
normalizedIntensity: normalizedIntensity(intervals),
|
normalizedIntensity: normalizedIntensity(intervals),
|
||||||
tss: tss(duration, normIntensity),
|
tss: tss(duration, normIntensity),
|
||||||
xp: xp(detectRepeats(intervals)),
|
|
||||||
zones: zoneDistribution(intervals),
|
zones: zoneDistribution(intervals),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatStats = ({ totalDuration, averageIntensity, normalizedIntensity, tss, xp, zones }: Stats) => {
|
export const formatStats = ({ totalDuration, averageIntensity, normalizedIntensity, tss, zones }: Stats) => {
|
||||||
return `
|
return `
|
||||||
Total duration: ${(totalDuration.seconds / 60).toFixed()} minutes
|
Total duration: ${(totalDuration.seconds / 60).toFixed()} minutes
|
||||||
|
|
||||||
|
|
@ -40,7 +36,6 @@ Average intensity: ${(averageIntensity.value * 100).toFixed()}%
|
||||||
Normalized intensity: ${(normalizedIntensity.value * 100).toFixed()}%
|
Normalized intensity: ${(normalizedIntensity.value * 100).toFixed()}%
|
||||||
|
|
||||||
TSS: ${tss.toFixed()}
|
TSS: ${tss.toFixed()}
|
||||||
XP: ${xp}
|
|
||||||
|
|
||||||
Zone Distribution:
|
Zone Distribution:
|
||||||
${zones.map(({ name, duration }) => `${(duration.seconds / 60).toFixed().padStart(3)} min - ${name}`).join("\n")}
|
${zones.map(({ name, duration }) => `${(duration.seconds / 60).toFixed().padStart(3)} min - ${name}`).join("\n")}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { map, pipe, sum } from "ramda";
|
import { pipe, sum } from "ramda";
|
||||||
import { Interval } from "../ast";
|
import { Interval } from "../ast";
|
||||||
import { ConstantIntensity } from "../Intensity";
|
import { ConstantIntensity } from "../Intensity";
|
||||||
import { average } from "./average";
|
import { average } from "./average";
|
||||||
|
|
@ -32,6 +32,12 @@ const fourthRoot = (x: number) => Math.pow(x, 1 / 4);
|
||||||
|
|
||||||
export const normalizedIntensity = (intervals: Interval[]): ConstantIntensity => {
|
export const normalizedIntensity = (intervals: Interval[]): ConstantIntensity => {
|
||||||
return new ConstantIntensity(
|
return new ConstantIntensity(
|
||||||
pipe(intervalsToIntensityNumbers, rollingAverages, map(fourthPower), average, fourthRoot)(intervals),
|
pipe(
|
||||||
|
intervalsToIntensityNumbers,
|
||||||
|
rollingAverages,
|
||||||
|
(averages) => averages.map(fourthPower),
|
||||||
|
average,
|
||||||
|
fourthRoot,
|
||||||
|
)(intervals),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import { xp } from "./xp";
|
|
||||||
import { Interval } from "../ast";
|
|
||||||
import { Duration } from "../Duration";
|
|
||||||
import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity";
|
|
||||||
import { RepeatedInterval } from "../detectRepeats";
|
|
||||||
|
|
||||||
describe("xp()", () => {
|
|
||||||
describe("ConstantIntensity interval", () => {
|
|
||||||
const createTestInterval = (seconds: number): Interval => ({
|
|
||||||
type: "Interval",
|
|
||||||
duration: new Duration(seconds),
|
|
||||||
intensity: new ConstantIntensity(100),
|
|
||||||
comments: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
[
|
|
||||||
[1, 0],
|
|
||||||
[2, 0],
|
|
||||||
[5, 0],
|
|
||||||
[6, 1],
|
|
||||||
[7, 1],
|
|
||||||
[10, 1],
|
|
||||||
[15, 2],
|
|
||||||
[30, 5],
|
|
||||||
[45, 8],
|
|
||||||
[50, 8],
|
|
||||||
[55, 9],
|
|
||||||
[56, 10],
|
|
||||||
[57, 10],
|
|
||||||
[58, 10],
|
|
||||||
[59, 10],
|
|
||||||
[60, 10],
|
|
||||||
[61, 10],
|
|
||||||
[62, 11],
|
|
||||||
[63, 11],
|
|
||||||
[64, 11],
|
|
||||||
[65, 11],
|
|
||||||
[1, 0],
|
|
||||||
[1, 0],
|
|
||||||
].forEach(([seconds, expectedXp]) => {
|
|
||||||
it(`${seconds}s produces ${expectedXp} XP`, () => {
|
|
||||||
expect(xp([createTestInterval(seconds)])).toEqual(expectedXp);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("RangeIntensity interval", () => {
|
|
||||||
const createTestInterval = (seconds: number): Interval => ({
|
|
||||||
type: "Warmup",
|
|
||||||
duration: new Duration(seconds),
|
|
||||||
intensity: new RangeIntensity(50, 75),
|
|
||||||
comments: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
[
|
|
||||||
// [50, 4], // Doesn't work :(
|
|
||||||
[51, 5],
|
|
||||||
[52, 5],
|
|
||||||
[53, 5],
|
|
||||||
[54, 5],
|
|
||||||
[55, 5],
|
|
||||||
[56, 5],
|
|
||||||
[57, 5],
|
|
||||||
[58, 5],
|
|
||||||
[59, 5],
|
|
||||||
[60, 6],
|
|
||||||
[61, 6],
|
|
||||||
[65, 6],
|
|
||||||
[66, 6],
|
|
||||||
[67, 6],
|
|
||||||
[68, 6],
|
|
||||||
[5 * 60, 30],
|
|
||||||
].forEach(([seconds, expectedXp]) => {
|
|
||||||
it(`${seconds}s produces ${expectedXp} XP`, () => {
|
|
||||||
expect(xp([createTestInterval(seconds)])).toEqual(expectedXp);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FreeRide interval", () => {
|
|
||||||
const createTestInterval = (seconds: number): Interval => ({
|
|
||||||
type: "FreeRide",
|
|
||||||
duration: new Duration(seconds),
|
|
||||||
intensity: new FreeIntensity(),
|
|
||||||
comments: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
[
|
|
||||||
[51, 5],
|
|
||||||
[52, 5],
|
|
||||||
[53, 5],
|
|
||||||
[54, 5],
|
|
||||||
[55, 5],
|
|
||||||
[56, 5],
|
|
||||||
[57, 5],
|
|
||||||
[58, 5],
|
|
||||||
[59, 5],
|
|
||||||
// [60, 6], // Doesn't work :(
|
|
||||||
[61, 6],
|
|
||||||
[62, 6],
|
|
||||||
[63, 6],
|
|
||||||
[64, 6],
|
|
||||||
[65, 6],
|
|
||||||
[66, 6],
|
|
||||||
[67, 6],
|
|
||||||
[68, 6],
|
|
||||||
[69, 6],
|
|
||||||
[2 * 60, 11],
|
|
||||||
[3 * 60, 17],
|
|
||||||
].forEach(([seconds, expectedXp]) => {
|
|
||||||
it(`${seconds}s produces ${expectedXp} XP`, () => {
|
|
||||||
expect(xp([createTestInterval(seconds)])).toEqual(expectedXp);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Repeated interval", () => {
|
|
||||||
const createTestInterval = (times: number, [onSeconds, offSeconds]: number[]): RepeatedInterval => ({
|
|
||||||
type: "repeat",
|
|
||||||
times,
|
|
||||||
intervals: [
|
|
||||||
{
|
|
||||||
type: "Interval",
|
|
||||||
duration: new Duration(onSeconds),
|
|
||||||
intensity: new ConstantIntensity(80),
|
|
||||||
comments: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "Interval",
|
|
||||||
duration: new Duration(offSeconds),
|
|
||||||
intensity: new ConstantIntensity(70),
|
|
||||||
comments: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
comments: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
[
|
|
||||||
{ times: 2, intervals: [1, 1], expectedXp: 0 }, // 0:04
|
|
||||||
{ times: 3, intervals: [1, 1], expectedXp: 1 }, // 0:06
|
|
||||||
{ times: 2, intervals: [14, 14], expectedXp: 11 }, // 0:56
|
|
||||||
{ times: 2, intervals: [14, 15], expectedXp: 11 }, // 0:58
|
|
||||||
{ times: 2, intervals: [15, 15], expectedXp: 11 }, // 1:00
|
|
||||||
{ times: 2, intervals: [15, 16], expectedXp: 12 }, // 1:02
|
|
||||||
{ times: 2, intervals: [16, 16], expectedXp: 12 }, // 1:04
|
|
||||||
{ times: 3, intervals: [14, 15], expectedXp: 17 }, // 1:27
|
|
||||||
{ times: 3, intervals: [15, 16], expectedXp: 18 }, // 1:33
|
|
||||||
{ times: 2, intervals: [30, 30], expectedXp: 23 }, // 2:00
|
|
||||||
{ times: 2, intervals: [60, 60], expectedXp: 47 }, // 4:00
|
|
||||||
].forEach(({ times, intervals, expectedXp }) => {
|
|
||||||
it(`${times} x (${intervals[0]}s on & ${intervals[1]}s off) produces ${expectedXp} XP`, () => {
|
|
||||||
expect(xp([createTestInterval(times, intervals)])).toEqual(expectedXp);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { Interval } from "../ast";
|
|
||||||
import { sum } from "ramda";
|
|
||||||
import { RangeIntensity, ConstantIntensity, FreeIntensity } from "../Intensity";
|
|
||||||
import { RepeatedInterval } from "../detectRepeats";
|
|
||||||
import { totalDuration } from "./totalDuration";
|
|
||||||
|
|
||||||
const intervalXp = (interval: Interval | RepeatedInterval): number => {
|
|
||||||
if (interval.type === "repeat") {
|
|
||||||
// 11.9 XP per minute (1 XP for every 5.05 seconds)
|
|
||||||
const duration = totalDuration(interval.intervals).seconds * interval.times;
|
|
||||||
return Math.floor(duration / 5.05); // Suitable numbers are: 5.01 .. 5.09
|
|
||||||
} else {
|
|
||||||
if (interval.intensity instanceof ConstantIntensity) {
|
|
||||||
// 10.8 XP per minute (1XP for every 5.56 seconds)
|
|
||||||
return Math.floor(interval.duration.seconds / 5.56);
|
|
||||||
} else if (interval.intensity instanceof RangeIntensity) {
|
|
||||||
// 6 XP per minute (1XP for every 10 seconds)
|
|
||||||
return Math.floor(interval.duration.seconds / 10);
|
|
||||||
} else if (interval.intensity instanceof FreeIntensity) {
|
|
||||||
// 5.9 XP per minute (1XP for every 10.1 seconds)
|
|
||||||
return Math.floor(interval.duration.seconds / 10.1);
|
|
||||||
} else {
|
|
||||||
throw new Error("Unknown type of intensity");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const xp = (intervals: (Interval | RepeatedInterval)[]): number => sum(intervals.map(intervalXp));
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { Interval } from "../ast";
|
||||||
|
import { Duration } from "../Duration";
|
||||||
|
import { ConstantIntensity, RangeIntensity } from "../Intensity";
|
||||||
|
import { chunkRangeIntervals } from "./chunkRangeIntervals";
|
||||||
|
|
||||||
|
describe("chunkRangeIntervals()", () => {
|
||||||
|
const minute = new Duration(60);
|
||||||
|
|
||||||
|
it("does nothing with empty array", () => {
|
||||||
|
expect(chunkRangeIntervals([], minute)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing with constant-intensity intervals", () => {
|
||||||
|
const intervals: Interval[] = [
|
||||||
|
{
|
||||||
|
type: "Interval",
|
||||||
|
duration: new Duration(2 * 60),
|
||||||
|
intensity: new ConstantIntensity(0.7),
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Interval",
|
||||||
|
duration: new Duration(10 * 60),
|
||||||
|
intensity: new ConstantIntensity(1),
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Rest",
|
||||||
|
duration: new Duration(30),
|
||||||
|
intensity: new ConstantIntensity(0.5),
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(chunkRangeIntervals(intervals, minute)).toEqual(intervals);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts 1-minute range-interval to 1-minute constant-interval", () => {
|
||||||
|
expect(
|
||||||
|
chunkRangeIntervals(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "Warmup",
|
||||||
|
duration: minute,
|
||||||
|
intensity: new RangeIntensity(0.5, 1),
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
minute,
|
||||||
|
),
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"comments": Array [],
|
||||||
|
"duration": Duration {
|
||||||
|
"seconds": 60,
|
||||||
|
},
|
||||||
|
"intensity": ConstantIntensity {
|
||||||
|
"_value": 0.75,
|
||||||
|
},
|
||||||
|
"type": "Warmup",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits 3-minute range-interval to three 1-minute constant-intervals", () => {
|
||||||
|
expect(
|
||||||
|
chunkRangeIntervals(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "Warmup",
|
||||||
|
duration: new Duration(3 * 60),
|
||||||
|
intensity: new RangeIntensity(0.5, 1),
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
minute,
|
||||||
|
),
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"comments": Array [],
|
||||||
|
"duration": Duration {
|
||||||
|
"seconds": 60,
|
||||||
|
},
|
||||||
|
"intensity": ConstantIntensity {
|
||||||
|
"_value": 0.5833333333333334,
|
||||||
|
},
|
||||||
|
"type": "Warmup",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"comments": Array [],
|
||||||
|
"duration": Duration {
|
||||||
|
"seconds": 60,
|
||||||
|
},
|
||||||
|
"intensity": ConstantIntensity {
|
||||||
|
"_value": 0.75,
|
||||||
|
},
|
||||||
|
"type": "Warmup",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"comments": Array [],
|
||||||
|
"duration": Duration {
|
||||||
|
"seconds": 60,
|
||||||
|
},
|
||||||
|
"intensity": ConstantIntensity {
|
||||||
|
"_value": 0.9166666666666667,
|
||||||
|
},
|
||||||
|
"type": "Warmup",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits 1:30 range-interval to 1min & 30sec constant-intervals", () => {
|
||||||
|
expect(
|
||||||
|
chunkRangeIntervals(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "Warmup",
|
||||||
|
duration: new Duration(60 + 30),
|
||||||
|
intensity: new RangeIntensity(0.5, 1),
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
minute,
|
||||||
|
),
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"comments": Array [],
|
||||||
|
"duration": Duration {
|
||||||
|
"seconds": 60,
|
||||||
|
},
|
||||||
|
"intensity": ConstantIntensity {
|
||||||
|
"_value": 0.6666666666666666,
|
||||||
|
},
|
||||||
|
"type": "Warmup",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"comments": Array [],
|
||||||
|
"duration": Duration {
|
||||||
|
"seconds": 30,
|
||||||
|
},
|
||||||
|
"intensity": ConstantIntensity {
|
||||||
|
"_value": 0.9166666666666667,
|
||||||
|
},
|
||||||
|
"type": "Warmup",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { chain, curry } from "ramda";
|
||||||
|
import { Interval } from "../ast";
|
||||||
|
import { Duration } from "../Duration";
|
||||||
|
import { ConstantIntensity, Intensity } from "../Intensity";
|
||||||
|
|
||||||
|
const chunkDuration = (seconds: number, chunkSize: Duration, intervalDuration: Duration): Duration => {
|
||||||
|
return seconds + chunkSize.seconds > intervalDuration.seconds
|
||||||
|
? new Duration(intervalDuration.seconds % chunkSize.seconds)
|
||||||
|
: chunkSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunkIntensity = (
|
||||||
|
startSeconds: number,
|
||||||
|
chunkSize: Duration,
|
||||||
|
{ start, end }: Intensity,
|
||||||
|
intervalDuration: Duration,
|
||||||
|
): ConstantIntensity => {
|
||||||
|
const endSeconds =
|
||||||
|
startSeconds + chunkSize.seconds > intervalDuration.seconds
|
||||||
|
? intervalDuration.seconds
|
||||||
|
: startSeconds + chunkSize.seconds;
|
||||||
|
|
||||||
|
const middleSeconds = (startSeconds + endSeconds) / 2;
|
||||||
|
|
||||||
|
return new ConstantIntensity(start + (end - start) * (middleSeconds / intervalDuration.seconds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunkInterval = curry((chunkSize: Duration, interval: Interval): Interval[] => {
|
||||||
|
if (interval.intensity.start === interval.intensity.end) {
|
||||||
|
return [interval];
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervals: Interval[] = [];
|
||||||
|
for (let seconds = 0; seconds < interval.duration.seconds; seconds += chunkSize.seconds) {
|
||||||
|
intervals.push({
|
||||||
|
...interval,
|
||||||
|
duration: chunkDuration(seconds, chunkSize, interval.duration),
|
||||||
|
intensity: chunkIntensity(seconds, chunkSize, interval.intensity, interval.duration),
|
||||||
|
comments: [], // TODO: for now, ignoring comments
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return intervals;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breaks intervals that use RangeIntensity into multiple intervals with ConstantIntensity
|
||||||
|
*/
|
||||||
|
export const chunkRangeIntervals = (intervals: Interval[], chunkSize: Duration): Interval[] =>
|
||||||
|
chain(chunkInterval(chunkSize), intervals);
|
||||||
|
|
@ -5,8 +5,6 @@ exports[`Generate ZWO examples/comments.txt 1`] = `
|
||||||
<name>Workout with comments</name>
|
<name>Workout with comments</name>
|
||||||
<author></author>
|
<author></author>
|
||||||
<description></description>
|
<description></description>
|
||||||
<tags>
|
|
||||||
</tags>
|
|
||||||
<sportType>bike</sportType>
|
<sportType>bike</sportType>
|
||||||
<workout>
|
<workout>
|
||||||
<Warmup Duration=\\"600\\" PowerLow=\\"0.25\\" PowerHigh=\\"0.75\\">
|
<Warmup Duration=\\"600\\" PowerLow=\\"0.25\\" PowerHigh=\\"0.75\\">
|
||||||
|
|
@ -72,8 +70,6 @@ exports[`Generate ZWO examples/darth-vader.txt 1`] = `
|
||||||
<name>Darth Vader</name>
|
<name>Darth Vader</name>
|
||||||
<author>HumanPowerPerformance.com</author>
|
<author>HumanPowerPerformance.com</author>
|
||||||
<description>Sign up for coaching with HumanPowerPerformance.com and get custom workouts and training plans</description>
|
<description>Sign up for coaching with HumanPowerPerformance.com and get custom workouts and training plans</description>
|
||||||
<tags>
|
|
||||||
</tags>
|
|
||||||
<sportType>bike</sportType>
|
<sportType>bike</sportType>
|
||||||
<workout>
|
<workout>
|
||||||
<SteadyState Duration=\\"300\\" Power=\\"0.55\\">
|
<SteadyState Duration=\\"300\\" Power=\\"0.55\\">
|
||||||
|
|
@ -97,12 +93,6 @@ Pace yourself and try to go as hard as you can sustain for the entire 20 minutes
|
||||||
you will be scored on the final 20 minute segment.
|
you will be scored on the final 20 minute segment.
|
||||||
|
|
||||||
Upon saving your ride, you will be notified if your FTP improved.</description>
|
Upon saving your ride, you will be notified if your FTP improved.</description>
|
||||||
<tags>
|
|
||||||
<tag name=\\"FTP\\">
|
|
||||||
</tag>
|
|
||||||
<tag name=\\"Test\\">
|
|
||||||
</tag>
|
|
||||||
</tags>
|
|
||||||
<sportType>bike</sportType>
|
<sportType>bike</sportType>
|
||||||
<workout>
|
<workout>
|
||||||
<Warmup Duration=\\"300\\" PowerLow=\\"0.3\\" PowerHigh=\\"0.7\\">
|
<Warmup Duration=\\"300\\" PowerLow=\\"0.3\\" PowerHigh=\\"0.7\\">
|
||||||
|
|
@ -164,10 +154,6 @@ exports[`Generate ZWO examples/halvfems.txt 1`] = `
|
||||||
<description>Named after the number 90 in Danish, this workout is focused sweet spot training, centered around 90% of FTP.
|
<description>Named after the number 90 in Danish, this workout is focused sweet spot training, centered around 90% of FTP.
|
||||||
This pairs with Devedeset (90 in Croatian) and Novanta (90 in Italian) to make up the sweet spot trifecta in this plan.
|
This pairs with Devedeset (90 in Croatian) and Novanta (90 in Italian) to make up the sweet spot trifecta in this plan.
|
||||||
The workouts are alphabetically ordered from easiest to hardest, so enjoy the middle of these three challenging workouts today...</description>
|
The workouts are alphabetically ordered from easiest to hardest, so enjoy the middle of these three challenging workouts today...</description>
|
||||||
<tags>
|
|
||||||
<tag name=\\"Intervals\\">
|
|
||||||
</tag>
|
|
||||||
</tags>
|
|
||||||
<sportType>bike</sportType>
|
<sportType>bike</sportType>
|
||||||
<workout>
|
<workout>
|
||||||
<Warmup Duration=\\"420\\" PowerLow=\\"0.25\\" PowerHigh=\\"0.75\\">
|
<Warmup Duration=\\"420\\" PowerLow=\\"0.25\\" PowerHigh=\\"0.75\\">
|
||||||
|
|
@ -226,31 +212,6 @@ The workouts are alphabetically ordered from easiest to hardest, so enjoy the mi
|
||||||
</workout_file>"
|
</workout_file>"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Generate ZWO examples/ramps.txt 1`] = `
|
|
||||||
"<workout_file>
|
|
||||||
<name>Ramps</name>
|
|
||||||
<author>R.Saarsoo</author>
|
|
||||||
<description>Various kinds of ramp intervals.</description>
|
|
||||||
<tags>
|
|
||||||
</tags>
|
|
||||||
<sportType>bike</sportType>
|
|
||||||
<workout>
|
|
||||||
<Warmup Duration=\\"300\\" PowerLow=\\"0.4\\" PowerHigh=\\"0.75\\">
|
|
||||||
</Warmup>
|
|
||||||
<Ramp Duration=\\"600\\" PowerLow=\\"0.8\\" PowerHigh=\\"0.9\\">
|
|
||||||
</Ramp>
|
|
||||||
<Ramp Duration=\\"600\\" PowerLow=\\"0.9\\" PowerHigh=\\"0.8\\">
|
|
||||||
</Ramp>
|
|
||||||
<Ramp Duration=\\"600\\" PowerLow=\\"0.8\\" PowerHigh=\\"0.9\\">
|
|
||||||
</Ramp>
|
|
||||||
<Ramp Duration=\\"600\\" PowerLow=\\"0.9\\" PowerHigh=\\"0.8\\">
|
|
||||||
</Ramp>
|
|
||||||
<Cooldown Duration=\\"300\\" PowerLow=\\"0.75\\" PowerHigh=\\"0.4\\">
|
|
||||||
</Cooldown>
|
|
||||||
</workout>
|
|
||||||
</workout_file>"
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Generate ZWO examples/threshold-pushing.txt 1`] = `
|
exports[`Generate ZWO examples/threshold-pushing.txt 1`] = `
|
||||||
"<workout_file>
|
"<workout_file>
|
||||||
<name>Threshold pushing</name>
|
<name>Threshold pushing</name>
|
||||||
|
|
@ -263,8 +224,6 @@ This session is designed to increase your FTP from below.
|
||||||
Working predominately aerobic metabolism.
|
Working predominately aerobic metabolism.
|
||||||
In time your body will become more comfortable at these powers
|
In time your body will become more comfortable at these powers
|
||||||
and your FTP will increase.</description>
|
and your FTP will increase.</description>
|
||||||
<tags>
|
|
||||||
</tags>
|
|
||||||
<sportType>bike</sportType>
|
<sportType>bike</sportType>
|
||||||
<workout>
|
<workout>
|
||||||
<Warmup Duration=\\"1200\\" PowerLow=\\"0.45\\" PowerHigh=\\"0.75\\">
|
<Warmup Duration=\\"1200\\" PowerLow=\\"0.45\\" PowerHigh=\\"0.75\\">
|
||||||
|
|
@ -287,7 +246,6 @@ Average intensity: 60%
|
||||||
Normalized intensity: 74%
|
Normalized intensity: 74%
|
||||||
|
|
||||||
TSS: 22
|
TSS: 22
|
||||||
XP: 173
|
|
||||||
|
|
||||||
Zone Distribution:
|
Zone Distribution:
|
||||||
15 min - Z1: Recovery
|
15 min - Z1: Recovery
|
||||||
|
|
@ -308,7 +266,6 @@ Average intensity: 79%
|
||||||
Normalized intensity: 84%
|
Normalized intensity: 84%
|
||||||
|
|
||||||
TSS: 51
|
TSS: 51
|
||||||
XP: 502
|
|
||||||
|
|
||||||
Zone Distribution:
|
Zone Distribution:
|
||||||
10 min - Z1: Recovery
|
10 min - Z1: Recovery
|
||||||
|
|
@ -325,11 +282,10 @@ exports[`Generate stats examples/ftp-test.txt 1`] = `
|
||||||
"
|
"
|
||||||
Total duration: 45 minutes
|
Total duration: 45 minutes
|
||||||
|
|
||||||
Average intensity: 72%
|
Average intensity: 36%
|
||||||
Normalized intensity: 81%
|
Normalized intensity: 71%
|
||||||
|
|
||||||
TSS: 49
|
TSS: 37
|
||||||
XP: 336
|
|
||||||
|
|
||||||
Zone Distribution:
|
Zone Distribution:
|
||||||
15 min - Z1: Recovery
|
15 min - Z1: Recovery
|
||||||
|
|
@ -350,7 +306,6 @@ Average intensity: 74%
|
||||||
Normalized intensity: 81%
|
Normalized intensity: 81%
|
||||||
|
|
||||||
TSS: 68
|
TSS: 68
|
||||||
XP: 628
|
|
||||||
|
|
||||||
Zone Distribution:
|
Zone Distribution:
|
||||||
22 min - Z1: Recovery
|
22 min - Z1: Recovery
|
||||||
|
|
@ -363,27 +318,6 @@ Zone Distribution:
|
||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Generate stats examples/ramps.txt 1`] = `
|
|
||||||
"
|
|
||||||
Total duration: 50 minutes
|
|
||||||
|
|
||||||
Average intensity: 80%
|
|
||||||
Normalized intensity: 82%
|
|
||||||
|
|
||||||
TSS: 56
|
|
||||||
XP: 300
|
|
||||||
|
|
||||||
Zone Distribution:
|
|
||||||
6 min - Z1: Recovery
|
|
||||||
4 min - Z2: Endurance
|
|
||||||
40 min - Z3: Tempo
|
|
||||||
0 min - Z4: Threshold
|
|
||||||
0 min - Z5: VO2 Max
|
|
||||||
0 min - Z6: Anaerobic
|
|
||||||
0 min - Freeride
|
|
||||||
"
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Generate stats examples/threshold-pushing.txt 1`] = `
|
exports[`Generate stats examples/threshold-pushing.txt 1`] = `
|
||||||
"
|
"
|
||||||
Total duration: 79 minutes
|
Total duration: 79 minutes
|
||||||
|
|
@ -392,7 +326,6 @@ Average intensity: 69%
|
||||||
Normalized intensity: 78%
|
Normalized intensity: 78%
|
||||||
|
|
||||||
TSS: 81
|
TSS: 81
|
||||||
XP: 755
|
|
||||||
|
|
||||||
Zone Distribution:
|
Zone Distribution:
|
||||||
42 min - Z1: Recovery
|
42 min - Z1: Recovery
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ const filenames = [
|
||||||
"examples/ftp-test.txt",
|
"examples/ftp-test.txt",
|
||||||
"examples/halvfems.txt",
|
"examples/halvfems.txt",
|
||||||
"examples/threshold-pushing.txt",
|
"examples/threshold-pushing.txt",
|
||||||
"examples/ramps.txt",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("Generate ZWO", () => {
|
describe("Generate ZWO", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue