Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

30 changed files with 297 additions and 1318 deletions

View File

@ -2,8 +2,6 @@
[Zwift][] workout generator command line tool and library.
Used as engine for the online [workout-editor][].
## Motivation
Creating custom workouts is a pain.
@ -18,7 +16,7 @@ Editing .zwo files by hand is also inconvenient:
- you'll have to constantly convert minutes to seconds,
- 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:
@ -49,7 +47,6 @@ Write a workout description like:
```
Name: Sample workout
Author: John Doe
Tags: Recovery, Intervals, FTP
Description: Try changing it, and see what happens below.
Warmup: 10:00 30%..75%
@ -115,11 +112,8 @@ console.log(stats(workout));
- Repeats (and nested repeats)
- Unsupported params: message duration & y-position
- More restricted syntax for text (with quotes)
- Concatenate similar intervals
- Distinguish between terrain-sensitive and insensitive free-ride.
- Support for tags
[zwift]: https://zwift.com/
[zwofactory]: https://zwofactory.com/
[simple zwo creator]: https://zwifthacks.com/app/simple-zwo-creator/
[workout-editor]: https://nene.github.io/workout-editor/
[zwo-sucks]: http://nene.github.io/2021/01/14/zwo-sucks

View File

@ -8,7 +8,6 @@ Description:
you will be scored on the final 20 minute segment.
Upon saving your ride, you will be notified if your FTP improved.
Tags: FTP, Test
Warmup: 5:00 30%..70%
@ 00:20 Welcome to the FTP test

View File

@ -3,7 +3,6 @@ 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.
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%
Interval: 00:30 95rpm 95%

View File

@ -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%

View File

@ -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%

View File

@ -1,13 +1,9 @@
{
"name": "zwiftout",
"version": "2.3.0",
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"description": "Zwift workout generator command line tool and library",
"author": "Rene Saarsoo <github@triin.net>",
"repository": {
"type": "git",
"url": "https://github.com/nene/zwiftout.git"
},
"scripts": {
"lint:ts": "tsc --noEmit",
"lint:js": "eslint 'src/**/*'",
@ -21,7 +17,7 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"zwiftout": "bin/zwiftout.js"
"zwiftout": "./bin/zwiftout.js"
},
"dependencies": {
"argparse": "^2.0.1",

View File

@ -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 {
get value() {
// To match Zwift, which gives 64 TSS for 1h of freeride.
return 0.8;
return 0;
}
get start() {
return this.value;
return 0;
}
get end() {
return this.value;
return 0;
}
get zone() {

View File

@ -1,4 +1,4 @@
import { IntervalType, SourceLocation } from "./parser/tokenizer";
import { IntervalType } from "./parser/tokenizer";
import { Duration } from "./Duration";
import { Intensity } from "./Intensity";
@ -6,7 +6,6 @@ export type Workout = {
name: string;
author: string;
description: string;
tags: string[];
intervals: Interval[];
};
@ -21,5 +20,4 @@ export type Interval = {
export type Comment = {
offset: Duration;
text: string;
loc: SourceLocation;
};

View File

@ -176,9 +176,9 @@ describe("detectRepeats()", () => {
duration: new Duration(100),
intensity: new ConstantIntensity(1),
comments: [
{ offset: new Duration(0), text: "Let's start", loc: { row: 1, col: 1 } },
{ offset: new Duration(20), text: "Stay strong!", loc: { row: 2, col: 1 } },
{ offset: new Duration(90), text: "Finish it!", loc: { row: 3, col: 1 } },
{ offset: new Duration(0), text: "Let's start" },
{ offset: new Duration(20), text: "Stay strong!" },
{ offset: new Duration(90), text: "Finish it!" },
],
},
{
@ -186,8 +186,8 @@ describe("detectRepeats()", () => {
duration: new Duration(100),
intensity: new ConstantIntensity(0.5),
comments: [
{ offset: new Duration(0), text: "Huh... have a rest", loc: { row: 4, col: 1 } },
{ offset: new Duration(80), text: "Ready for next?", loc: { row: 5, col: 1 } },
{ offset: new Duration(0), text: "Huh... have a rest" },
{ offset: new Duration(80), text: "Ready for next?" },
],
},
{
@ -195,9 +195,9 @@ describe("detectRepeats()", () => {
duration: new Duration(100),
intensity: new ConstantIntensity(1),
comments: [
{ offset: new Duration(0), text: "Bring it on again!", loc: { row: 6, col: 1 } },
{ offset: new Duration(50), text: "Half way", loc: { row: 7, col: 1 } },
{ offset: new Duration(90), text: "Almost there!", loc: { row: 8, col: 1 } },
{ offset: new Duration(0), text: "Bring it on again!" },
{ offset: new Duration(50), text: "Half way" },
{ offset: new Duration(90), text: "Almost there!" },
],
},
{
@ -205,9 +205,9 @@ describe("detectRepeats()", () => {
duration: new Duration(100),
intensity: new ConstantIntensity(0.5),
comments: [
{ offset: new Duration(30), text: "Wow... you did it!", loc: { row: 9, col: 1 } },
{ offset: new Duration(40), text: "Nice job.", loc: { row: 10, col: 1 } },
{ offset: new Duration(50), text: "Until next time...", loc: { row: 11, col: 1 } },
{ offset: new Duration(30), text: "Wow... you did it!" },
{ offset: new Duration(40), text: "Nice job." },
{ 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: [] },
],
comments: [
{ offset: new Duration(0), text: "Let's start", loc: { row: 1, col: 1 } },
{ offset: new Duration(20), text: "Stay strong!", loc: { row: 2, col: 1 } },
{ offset: new Duration(90), text: "Finish it!", loc: { row: 3, col: 1 } },
{ offset: new Duration(0), text: "Let's start" },
{ offset: new Duration(20), text: "Stay strong!" },
{ offset: new Duration(90), text: "Finish it!" },
{ offset: new Duration(100), text: "Huh... have a rest", loc: { row: 4, col: 1 } },
{ offset: new Duration(180), text: "Ready for next?", loc: { row: 5, col: 1 } },
{ offset: new Duration(100), text: "Huh... have a rest" },
{ 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(250), text: "Half way", loc: { row: 7, col: 1 } },
{ offset: new Duration(290), text: "Almost there!", loc: { row: 8, col: 1 } },
{ offset: new Duration(200), text: "Bring it on again!" },
{ offset: new Duration(250), text: "Half way" },
{ offset: new Duration(290), text: "Almost there!" },
{ offset: new Duration(330), text: "Wow... you did it!", loc: { row: 9, col: 1 } },
{ offset: new Duration(340), text: "Nice job.", loc: { row: 10, col: 1 } },
{ offset: new Duration(350), text: "Until next time...", loc: { row: 11, col: 1 } },
{ offset: new Duration(330), text: "Wow... you did it!" },
{ offset: new Duration(340), text: "Nice job." },
{ offset: new Duration(350), text: "Until next time..." },
],
},
]);

View File

@ -1,6 +1,7 @@
import { eqProps, flatten, zip } from "ramda";
import { Interval, Comment } from "./ast";
import { Duration } from "./Duration";
import { RangeIntensity } from "./Intensity";
export type RepeatedInterval = {
type: "repeat";
@ -69,7 +70,7 @@ const extractRepeatedInterval = (intervals: Interval[], i: number): RepeatedInte
};
};
const isRangeInterval = ({ intensity }: Interval): boolean => intensity.start !== intensity.end;
const isRangeInterval = (interval: Interval): boolean => interval.intensity instanceof RangeIntensity;
export const detectRepeats = (intervals: Interval[]): (Interval | RepeatedInterval)[] => {
if (intervals.length < windowSize) {

View File

@ -1,6 +1,7 @@
import * as xml from "xml";
import { Interval, Workout, Comment } from "./ast";
import { detectRepeats, RepeatedInterval } from "./detectRepeats";
import { FreeIntensity } from "./Intensity";
// Zwift Workout XML generator
@ -11,7 +12,7 @@ const generateTextEvents = (comments: Comment[]): xml.XmlObject[] => {
};
const generateRangeInterval = (
tagName: "Warmup" | "Cooldown" | "Ramp",
tagName: "Warmup" | "Cooldown",
{ duration, intensity, cadence, comments }: Interval,
): xml.XmlObject => {
return {
@ -79,43 +80,30 @@ const generateRepeatInterval = (repInterval: RepeatedInterval): xml.XmlObject =>
};
};
const generateInterval = (
interval: Interval | RepeatedInterval,
index: number,
allIntervals: (Interval | RepeatedInterval)[],
): xml.XmlObject => {
const generateInterval = (interval: Interval | RepeatedInterval): xml.XmlObject => {
if (interval.type === "repeat") {
return generateRepeatInterval(interval);
}
const { intensity } = interval;
if (index === 0 && intensity.start < intensity.end) {
if (intensity.start < intensity.end) {
return generateRangeInterval("Warmup", interval);
} else if (index === allIntervals.length - 1 && intensity.start > intensity.end) {
} else if (intensity.start > intensity.end) {
return generateRangeInterval("Cooldown", interval);
} else if (intensity.start !== intensity.end) {
return generateRangeInterval("Ramp", interval);
} else if (intensity.zone === "free") {
} else if (intensity instanceof FreeIntensity) {
return generateFreeRideInterval(interval);
} else {
return generateSteadyStateInterval(interval);
}
};
const generateTag = (name: string): xml.XmlObject => {
return {
tag: [{ _attr: { name } }],
};
};
export const generateZwo = ({ name, author, description, tags, intervals }: Workout): string => {
export const generateZwo = ({ name, author, description, intervals }: Workout): string => {
return xml(
{
workout_file: [
{ name: name },
{ author: author },
{ description: description },
{ tags: tags.map(generateTag) },
{ sportType: "bike" },
{ workout: detectRepeats(intervals).map(generateInterval) },
],

View File

@ -7,13 +7,9 @@ export { parseCliOptions } from "./parseCliOptions";
export { Workout, Interval, Comment } from "./ast";
export { Duration } from "./Duration";
export { Intensity, ConstantIntensity, RangeIntensity, FreeIntensity } from "./Intensity";
export { ZoneType, intensityValueToZoneType } 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 };
export { ZoneType } from "./ZoneType";
// utils
export { totalDuration } from "./stats/totalDuration";
export { maximumIntensity } from "./stats/maximumIntensity";
export { chunkRangeIntervals } from "./utils/chunkRangeIntervals";

View File

@ -17,10 +17,13 @@ export const parseCliOptions = (): CliOptions => {
default: false,
});
argParser.add_argument("file", {
nargs: "?",
default: 0, // Default to reading STDIN
});
argParser.add_argument("file", { nargs: 1 });
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,
};
};

View File

@ -1,9 +1,7 @@
import { SourceLocation } from "./tokenizer";
export class ParseError extends Error {
public loc: SourceLocation;
constructor(msg: string, loc: SourceLocation) {
super(`${msg} at line ${loc.row + 1} char ${loc.col + 1}`);
this.loc = loc;
constructor(msg: string, { row, col }: SourceLocation) {
super(`${msg} at line ${row + 1} char ${col + 1}`);
}
}

View File

@ -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;
}
}

View File

@ -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),
};
};

View File

@ -1,7 +1,5 @@
import { Workout } from "../ast";
import { fillRangeIntensities } from "./fillRangeIntensities";
import { parseTokens } from "./parser";
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));

View File

@ -8,7 +8,6 @@ describe("Parser", () => {
"description": "",
"intervals": Array [],
"name": "Untitled",
"tags": Array [],
}
`);
@ -18,7 +17,6 @@ describe("Parser", () => {
"description": "",
"intervals": Array [],
"name": "Untitled",
"tags": Array [],
}
`);
});
@ -30,12 +28,11 @@ describe("Parser", () => {
"description": "",
"intervals": Array [],
"name": "My Workout",
"tags": Array [],
}
`);
});
it("parses workout header with name, author, description", () => {
it("parses workout header with all fields", () => {
expect(
parse(`
Name: My Workout
@ -55,47 +52,6 @@ Description:
it'll cause lots of pain.",
"intervals": Array [],
"name": "My Workout",
"tags": Array [],
}
`);
});
it("parses workout header with comma-separated tags", () => {
expect(
parse(`
Name: My Workout
Tags: Recovery, Intervals , FTP
`),
).toMatchInlineSnapshot(`
Object {
"author": "",
"description": "",
"intervals": Array [],
"name": "My Workout",
"tags": Array [
"Recovery",
"Intervals",
"FTP",
],
}
`);
});
it("treats with space-separated tags as single tag", () => {
expect(
parse(`
Name: My Workout
Tags: Recovery Intervals FTP
`),
).toMatchInlineSnapshot(`
Object {
"author": "",
"description": "",
"intervals": Array [],
"name": "My Workout",
"tags": Array [
"Recovery Intervals FTP",
],
}
`);
});
@ -219,7 +175,6 @@ Interval: 5:00 50%
},
],
"name": "My Workout",
"tags": Array [],
}
`);
});
@ -231,7 +186,6 @@ Name: My Workout
Warmup: 5:30 50%..80% 100rpm
Cooldown: 5:30 70%..45%
Ramp: 5:30 90%..100%
`).intervals,
).toMatchInlineSnapshot(`
Array [
@ -259,147 +213,10 @@ Ramp: 5:30 90%..100%
},
"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", () => {
expect(
parse(`
@ -422,34 +239,15 @@ FreeRide: 5:00
`);
});
it("Treats any interval without intensity as a free-ride interval", () => {
expect(
parse(`
Name: My Workout
Interval: 5:00
`).intervals,
).toMatchInlineSnapshot(`
Array [
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": FreeIntensity {},
"type": "Interval",
},
]
`);
});
const parseInterval = (interval: string) => parse(`Name: My Workout\n${interval}`).intervals[0];
it("requires duration parameter to be specified", () => {
it("requires duration and power parameters to be specified", () => {
expect(() => parseInterval("Interval: 50%")).toThrowErrorMatchingInlineSnapshot(
`"Duration not specified at line 2 char 1"`,
);
expect(() => parseInterval("Interval: 30:00")).toThrowErrorMatchingInlineSnapshot(
`"Power not specified at line 2 char 1"`,
);
expect(() => parseInterval("Interval: 10rpm")).toThrowErrorMatchingInlineSnapshot(
`"Duration not specified at line 2 char 1"`,
);
@ -596,50 +394,30 @@ Rest: 5:00 50%
"cadence": undefined,
"comments": Array [
Object {
"loc": Object {
"col": 4,
"row": 3,
},
"offset": Duration {
"seconds": 0,
},
"text": "Find your rythm.",
},
Object {
"loc": Object {
"col": 4,
"row": 4,
},
"offset": Duration {
"seconds": 60,
},
"text": "Try to settle in for the effort",
},
Object {
"loc": Object {
"col": 4,
"row": 6,
},
"offset": Duration {
"seconds": 300,
},
"text": "Half way through",
},
Object {
"loc": Object {
"col": 4,
"row": 8,
},
"offset": Duration {
"seconds": 540,
},
"text": "Almost there",
},
Object {
"loc": Object {
"col": 4,
"row": 9,
},
"offset": Duration {
"seconds": 570,
},
@ -658,20 +436,12 @@ Rest: 5:00 50%
"cadence": undefined,
"comments": Array [
Object {
"loc": Object {
"col": 4,
"row": 12,
},
"offset": Duration {
"seconds": 0,
},
"text": "Great effort!",
},
Object {
"loc": Object {
"col": 4,
"row": 13,
},
"offset": Duration {
"seconds": 30,
},
@ -688,479 +458,7 @@ Rest: 5:00 50%
},
],
"name": "My Workout",
"tags": Array [],
}
`);
});
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();
});
});

View File

@ -1,9 +1,8 @@
import { last } from "ramda";
import { Interval, Workout, Comment } from "../ast";
import { Duration } from "../Duration";
import { ConstantIntensity, FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity";
import { ParseError } from "./ParseError";
import { IntervalType, OffsetToken, SourceLocation, Token } from "./tokenizer";
import { IntervalType, SourceLocation, Token } from "./tokenizer";
type Header = Partial<Omit<Workout, "intervals">>;
@ -44,11 +43,6 @@ const parseHeader = (tokens: Token[]): [Header, Token[]] => {
const [description, rest] = extractText(tokens);
header.description = description;
tokens = rest;
} else if (token.type === "header" && token.value === "Tags") {
tokens.shift();
const [tags, rest] = extractText(tokens);
header.tags = tags.split(/\s*,\s*/);
tokens = rest;
} else {
// End of header
break;
@ -58,18 +52,12 @@ const parseHeader = (tokens: Token[]): [Header, Token[]] => {
return [header, tokens];
};
type PartialComment = {
offsetToken: OffsetToken;
text: string;
loc: SourceLocation;
};
const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Comment[], Token[]] => {
const comments: PartialComment[] = [];
const parseIntervalComments = (tokens: Token[]): [Comment[], Token[]] => {
const comments: Comment[] = [];
while (tokens[0]) {
const [start, offset, text, ...rest] = tokens;
if (start.type === "comment-start") {
if (!offset || offset.type !== "offset") {
if (!offset || offset.type !== "duration") {
throw new ParseError(
`Expected [comment offset] instead got ${tokenToString(offset)}`,
offset?.loc || start.loc,
@ -79,64 +67,15 @@ const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Co
throw new ParseError(`Expected [comment text] instead got ${tokenToString(text)}`, text?.loc || offset.loc);
}
comments.push({
offsetToken: offset,
offset: new Duration(offset.value),
text: text.value,
loc: offset.loc,
});
tokens = rest;
} else {
break;
}
}
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);
}
return [comments, tokens];
};
const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLocation): [Interval, Token[]] => {
@ -158,9 +97,6 @@ const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLoc
} else if (token.type === "intensity-range") {
intensity = new RangeIntensity(token.value[0], token.value[1]);
tokens.shift();
} else if (token.type === "intensity-range-end") {
intensity = new RangeIntensityEnd(token.value);
tokens.shift();
} else {
break;
}
@ -170,10 +106,14 @@ const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLoc
throw new ParseError("Duration not specified", loc);
}
if (!intensity) {
if (type === "FreeRide") {
intensity = new FreeIntensity();
} else {
throw new ParseError("Power not specified", loc);
}
}
const [comments, rest] = parseIntervalComments(tokens, duration);
const [comments, rest] = parseIntervalComments(tokens);
return [{ type, duration, intensity, cadence, comments }, rest];
};
@ -202,7 +142,6 @@ export const parseTokens = (tokens: Token[]): Workout => {
name: header.name || "Untitled",
author: header.author || "",
description: header.description || "",
tags: header.tags || [],
intervals: parseIntervals(intervalTokens),
};
};

View File

@ -1,13 +1,13 @@
import { ParseError } from "./ParseError";
export type HeaderType = "Name" | "Author" | "Description" | "Tags";
export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide" | "Ramp";
export type HeaderType = "Name" | "Author" | "Description";
export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide";
const isHeaderType = (value: string): value is HeaderType => {
return ["Name", "Author", "Description", "Tags"].includes(value);
return ["Name", "Author", "Description"].includes(value);
};
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.
@ -36,36 +36,17 @@ export type NumberToken = {
value: number;
loc: SourceLocation;
};
export type OffsetToken = {
type: "offset";
kind: "absolute" | "relative-plus" | "relative-minus";
value: number;
loc: SourceLocation;
};
export type RangeIntensityToken = {
type: "intensity-range";
value: [number, number];
loc: SourceLocation;
};
export type RangeIntensityEndToken = {
type: "intensity-range-end";
value: number;
loc: SourceLocation;
};
export type CommentStartToken = {
type: "comment-start";
value?: undefined;
loc: SourceLocation;
};
export type Token =
| HeaderToken
| IntervalToken
| TextToken
| NumberToken
| OffsetToken
| RangeIntensityToken
| RangeIntensityEndToken
| CommentStartToken;
export type Token = HeaderToken | IntervalToken | TextToken | NumberToken | RangeIntensityToken | CommentStartToken;
const toInteger = (str: string): number => {
return parseInt(str.replace(/[^0-9]/, ""), 10);
@ -87,14 +68,10 @@ const tokenizeValueParam = (text: string, loc: SourceLocation): Token => {
if (/^[0-9]+rpm$/.test(text)) {
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);
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)) {
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 [, commentHead, sign, offset, commentText] = line.match(/^(\s*@\s*)([-+]?)([0-9:]+)(.*?)$/) || [];
const [, commentHead, offset, commentText] = line.match(/^(\s*@\s*)([0-9:]+)(.*?)$/) || [];
if (!commentHead) {
return undefined;
}
@ -121,27 +98,11 @@ const tokenizeComment = (line: string, row: number): Token[] | undefined => {
}
return [
{ type: "comment-start", loc: { row, col: line.indexOf("@") } },
{
type: "offset",
kind: signToKind(sign),
value: toSeconds(offset),
loc: { row, col: commentHead.length },
},
{ type: "duration", value: toSeconds(offset), loc: { row, col: commentHead.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 token: HeaderToken = {
type: "header",

View File

@ -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;
};

View File

@ -1,12 +1,10 @@
import { Workout } from "../ast";
import { detectRepeats } from "../detectRepeats";
import { Duration } from "../Duration";
import { Intensity } from "../Intensity";
import { averageIntensity } from "./averageIntensity";
import { normalizedIntensity } from "./normalizedIntensity";
import { totalDuration } from "./totalDuration";
import { tss } from "./tss";
import { xp } from "./xp";
import { zoneDistribution, ZoneDuration } from "./zoneDistribution";
export type Stats = {
@ -14,7 +12,6 @@ export type Stats = {
averageIntensity: Intensity;
normalizedIntensity: Intensity;
tss: number;
xp: number;
zones: ZoneDuration[];
};
@ -27,12 +24,11 @@ export const stats = ({ intervals }: Workout): Stats => {
averageIntensity: averageIntensity(intervals),
normalizedIntensity: normalizedIntensity(intervals),
tss: tss(duration, normIntensity),
xp: xp(detectRepeats(intervals)),
zones: zoneDistribution(intervals),
};
};
export const formatStats = ({ totalDuration, averageIntensity, normalizedIntensity, tss, xp, zones }: Stats) => {
export const formatStats = ({ totalDuration, averageIntensity, normalizedIntensity, tss, zones }: Stats) => {
return `
Total duration: ${(totalDuration.seconds / 60).toFixed()} minutes
@ -40,7 +36,6 @@ Average intensity: ${(averageIntensity.value * 100).toFixed()}%
Normalized intensity: ${(normalizedIntensity.value * 100).toFixed()}%
TSS: ${tss.toFixed()}
XP: ${xp}
Zone Distribution:
${zones.map(({ name, duration }) => `${(duration.seconds / 60).toFixed().padStart(3)} min - ${name}`).join("\n")}

View File

@ -1,4 +1,4 @@
import { map, pipe, sum } from "ramda";
import { pipe, sum } from "ramda";
import { Interval } from "../ast";
import { ConstantIntensity } from "../Intensity";
import { average } from "./average";
@ -32,6 +32,12 @@ const fourthRoot = (x: number) => Math.pow(x, 1 / 4);
export const normalizedIntensity = (intervals: Interval[]): ConstantIntensity => {
return new ConstantIntensity(
pipe(intervalsToIntensityNumbers, rollingAverages, map(fourthPower), average, fourthRoot)(intervals),
pipe(
intervalsToIntensityNumbers,
rollingAverages,
(averages) => averages.map(fourthPower),
average,
fourthRoot,
)(intervals),
);
};

View File

@ -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);
});
});
});
});

View File

@ -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));

View File

@ -1,5 +1,6 @@
import { Interval } from "../ast";
import { Duration } from "../Duration";
import { RangeIntensity } from "../Intensity";
import { intensityValueToZoneType, ZoneType } from "../ZoneType";
import { intervalsToIntensityNumbers } from "./intervalsToIntensityNumbers";
@ -20,12 +21,12 @@ export const zoneDistribution = (intervals: Interval[]): ZoneDuration[] => {
const zones = emptyZones();
intervals.forEach((interval) => {
if (interval.intensity.start === interval.intensity.end) {
zones[interval.intensity.zone].duration += interval.duration.seconds;
} else {
if (interval.intensity instanceof RangeIntensity) {
intervalsToIntensityNumbers([interval]).forEach((intensityValue) => {
zones[intensityValueToZoneType(intensityValue)].duration++;
});
} else {
zones[interval.intensity.zone].duration += interval.duration.seconds;
}
});

View File

@ -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",
},
]
`);
});
});

View File

@ -0,0 +1,49 @@
import { chain, curry } from "ramda";
import { Interval } from "../ast";
import { Duration } from "../Duration";
import { ConstantIntensity, Intensity, RangeIntensity } 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 instanceof RangeIntensity)) {
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);

View File

@ -5,8 +5,6 @@ exports[`Generate ZWO examples/comments.txt 1`] = `
<name>Workout with comments</name>
<author></author>
<description></description>
<tags>
</tags>
<sportType>bike</sportType>
<workout>
<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>
<author>HumanPowerPerformance.com</author>
<description>Sign up for coaching with HumanPowerPerformance.com and get custom workouts and training plans</description>
<tags>
</tags>
<sportType>bike</sportType>
<workout>
<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.
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>
<workout>
<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.
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>
<tags>
<tag name=\\"Intervals\\">
</tag>
</tags>
<sportType>bike</sportType>
<workout>
<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>"
`;
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`] = `
"<workout_file>
<name>Threshold pushing</name>
@ -263,8 +224,6 @@ This session is designed to increase your FTP from below.
Working predominately aerobic metabolism.
In time your body will become more comfortable at these powers
and your FTP will increase.</description>
<tags>
</tags>
<sportType>bike</sportType>
<workout>
<Warmup Duration=\\"1200\\" PowerLow=\\"0.45\\" PowerHigh=\\"0.75\\">
@ -287,7 +246,6 @@ Average intensity: 60%
Normalized intensity: 74%
TSS: 22
XP: 173
Zone Distribution:
15 min - Z1: Recovery
@ -308,7 +266,6 @@ Average intensity: 79%
Normalized intensity: 84%
TSS: 51
XP: 502
Zone Distribution:
10 min - Z1: Recovery
@ -325,11 +282,10 @@ exports[`Generate stats examples/ftp-test.txt 1`] = `
"
Total duration: 45 minutes
Average intensity: 72%
Normalized intensity: 81%
Average intensity: 36%
Normalized intensity: 71%
TSS: 49
XP: 336
TSS: 37
Zone Distribution:
15 min - Z1: Recovery
@ -350,7 +306,6 @@ Average intensity: 74%
Normalized intensity: 81%
TSS: 68
XP: 628
Zone Distribution:
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`] = `
"
Total duration: 79 minutes
@ -392,7 +326,6 @@ Average intensity: 69%
Normalized intensity: 78%
TSS: 81
XP: 755
Zone Distribution:
42 min - Z1: Recovery

View File

@ -12,7 +12,6 @@ const filenames = [
"examples/ftp-test.txt",
"examples/halvfems.txt",
"examples/threshold-pushing.txt",
"examples/ramps.txt",
];
describe("Generate ZWO", () => {