├── .gitignore
├── .prettierrc
├── .npmignore
├── jest.config.js
├── src
├── stats
│ ├── average.ts
│ ├── totalDuration.ts
│ ├── maximumIntensity.ts
│ ├── averageIntensity.ts
│ ├── intervalsToIntensityNumbers.ts
│ ├── tss.ts
│ ├── xp.ts
│ ├── zoneDistribution.ts
│ ├── normalizedIntensity.ts
│ ├── index.ts
│ ├── normalizedIntensity.test.ts
│ ├── zoneDistribution.test.ts
│ └── xp.test.ts
├── Duration.ts
├── parser
│ ├── ParseError.ts
│ ├── ValidationError.ts
│ ├── index.ts
│ ├── validate.ts
│ ├── fillRangeIntensities.ts
│ ├── parser.ts
│ ├── tokenizer.ts
│ └── parser.test.ts
├── ZoneType.ts
├── ast.ts
├── parseCliOptions.ts
├── index.ts
├── Intensity.ts
├── detectRepeats.ts
├── generateZwo.ts
└── detectRepeats.test.ts
├── tsconfig.release.json
├── examples
├── ramps.txt
├── threshold-dev.txt
├── darth-vader.txt
├── threshold-pushing.txt
├── comments.txt
├── threshold-pushing.zwo
├── ftp-test.txt
├── halvfems.txt
└── ftp-test.zwo
├── tsconfig.json
├── bin
└── zwiftout.js
├── .eslintrc.js
├── test
├── cli.test.ts
└── __snapshots__
│ └── cli.test.ts.snap
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | yarn-error.log
3 | dist/
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "trailingComma": "all"
4 | }
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # To ensure that /dist dir (ignored by .gitignore) is included by npm
2 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: "ts-jest",
3 | testEnvironment: "node",
4 | };
5 |
--------------------------------------------------------------------------------
/src/stats/average.ts:
--------------------------------------------------------------------------------
1 | import { sum } from "ramda";
2 |
3 | export const average = (arr: number[]) => sum(arr) / arr.length;
4 |
--------------------------------------------------------------------------------
/tsconfig.release.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": [
4 | "src/**/*",
5 | ],
6 | "exclude": [
7 | "**/*.test.ts",
8 | ],
9 | }
10 |
--------------------------------------------------------------------------------
/src/Duration.ts:
--------------------------------------------------------------------------------
1 | export class Duration {
2 | constructor(readonly seconds: number) {}
3 |
4 | add(other: Duration): Duration {
5 | return new Duration(this.seconds + other.seconds);
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/stats/totalDuration.ts:
--------------------------------------------------------------------------------
1 | import { Interval } from "../ast";
2 | import { Duration } from "../Duration";
3 |
4 | export const totalDuration = (intervals: Interval[]): Duration =>
5 | intervals.reduce((total, interval) => total.add(interval.duration), new Duration(0));
6 |
--------------------------------------------------------------------------------
/examples/ramps.txt:
--------------------------------------------------------------------------------
1 | Name: Ramps
2 | Author: R.Saarsoo
3 | Description:
4 | Various kinds of ramp intervals.
5 |
6 | Ramp: 5:00 40%..75%
7 |
8 | Ramp: 10:00 80%..90%
9 | Ramp: 10:00 90%..80%
10 |
11 | Warmup: 10:00 80%..90%
12 | Cooldown: 10:00 90%..80%
13 |
14 | Ramp: 5:00 75%..40%
15 |
--------------------------------------------------------------------------------
/src/parser/ParseError.ts:
--------------------------------------------------------------------------------
1 | import { SourceLocation } from "./tokenizer";
2 |
3 | export class ParseError extends Error {
4 | public loc: SourceLocation;
5 | constructor(msg: string, loc: SourceLocation) {
6 | super(`${msg} at line ${loc.row + 1} char ${loc.col + 1}`);
7 | this.loc = loc;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/parser/ValidationError.ts:
--------------------------------------------------------------------------------
1 | import { SourceLocation } from "./tokenizer";
2 |
3 | export class ValidationError extends Error {
4 | public loc: SourceLocation;
5 | constructor(msg: string, loc: SourceLocation) {
6 | super(`${msg} at line ${loc.row + 1} char ${loc.col + 1}`);
7 | this.loc = loc;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/stats/maximumIntensity.ts:
--------------------------------------------------------------------------------
1 | import { Interval } from "../ast";
2 | import { ConstantIntensity } from "../Intensity";
3 |
4 | export const maximumIntensity = (intervals: Interval[]): ConstantIntensity =>
5 | new ConstantIntensity(
6 | Math.max(...intervals.map((interval) => Math.max(interval.intensity.start, interval.intensity.end))),
7 | );
8 |
--------------------------------------------------------------------------------
/src/parser/index.ts:
--------------------------------------------------------------------------------
1 | import { Workout } from "../ast";
2 | import { fillRangeIntensities } from "./fillRangeIntensities";
3 | import { parseTokens } from "./parser";
4 | import { tokenize } from "./tokenizer";
5 | import { validate } from "./validate";
6 |
7 | export const parse = (source: string): Workout => validate(fillRangeIntensities(parseTokens(tokenize(source))));
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "target": "es6",
5 | "sourceMap": true,
6 | "outDir": "dist",
7 | "strict": true,
8 | "noUnusedLocals": true,
9 | "noUnusedParameters": true,
10 | "noImplicitReturns": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "declaration": true,
13 | },
14 | "include": [
15 | "src/**/*",
16 | "test/**/*"
17 | ],
18 | }
19 |
--------------------------------------------------------------------------------
/src/stats/averageIntensity.ts:
--------------------------------------------------------------------------------
1 | import { pipe } from "ramda";
2 | import { Interval } from "../ast";
3 | import { ConstantIntensity } from "../Intensity";
4 | import { average } from "./average";
5 | import { intervalsToIntensityNumbers } from "./intervalsToIntensityNumbers";
6 |
7 | export const averageIntensity = (intervals: Interval[]): ConstantIntensity => {
8 | return new ConstantIntensity(pipe(intervalsToIntensityNumbers, average)(intervals));
9 | };
10 |
--------------------------------------------------------------------------------
/bin/zwiftout.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | /* eslint-disable @typescript-eslint/no-var-requires */
3 | const fs = require("fs");
4 | const { generateZwo, parse, stats, formatStats, parseCliOptions } = require("../dist/index");
5 |
6 | const opts = parseCliOptions();
7 |
8 | const workout = parse(fs.readFileSync(opts.file, "utf8"));
9 |
10 | if (opts.stats) {
11 | console.log(formatStats(stats(workout)));
12 | } else {
13 | console.log(generateZwo(workout));
14 | }
15 |
--------------------------------------------------------------------------------
/examples/threshold-dev.txt:
--------------------------------------------------------------------------------
1 | Name: Threshold Dev
2 | Description: From 10-12WK FTP Builder
3 | Author: Zwift
4 |
5 | Warmup: 10:00 50%..65%
6 | Warmup: 5:00 50%..100%
7 | Interval: 2:00 65%
8 | Interval: 2:00 81%
9 | Interval: 1:00 95%
10 |
11 | Rest: 5:00 50%
12 |
13 | Interval: 12:00 81%
14 | Rest: 8:00 50%
15 | Interval: 12:00 81%
16 | Rest: 8:00 50%
17 |
18 | Interval: 5:00 95%
19 | Rest: 2:00 50%
20 | Interval: 5:00 95%
21 | Rest: 2:00 50%
22 | Interval: 5:00 95%
23 | Rest: 2:00 50%
24 | Interval: 5:00 95%
25 | Rest: 2:00 50%
26 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "parser": "@typescript-eslint/parser",
3 | "parserOptions": { "sourceType": "module" },
4 | "env": { "node": true },
5 | "extends": [
6 | "eslint:recommended",
7 | "prettier",
8 | "plugin:@typescript-eslint/recommended"
9 | ],
10 | "plugins": [
11 | "@typescript-eslint"
12 | ],
13 | "rules": {
14 | "@typescript-eslint/explicit-module-boundary-types": "off",
15 | // Better handled by TypeScript
16 | "@typescript-eslint/no-unused-vars": "off",
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/src/ZoneType.ts:
--------------------------------------------------------------------------------
1 | export type ZoneType = "Z1" | "Z2" | "Z3" | "Z4" | "Z5" | "Z6" | "free";
2 |
3 | // Intensity ranges based on https://zwiftinsider.com/power-zone-colors/
4 | export const intensityValueToZoneType = (intensity: number): ZoneType => {
5 | if (intensity >= 1.18) {
6 | return "Z6";
7 | }
8 | if (intensity >= 1.05) {
9 | return "Z5";
10 | }
11 | if (intensity >= 0.9) {
12 | return "Z4";
13 | }
14 | if (intensity >= 0.75) {
15 | return "Z3";
16 | }
17 | if (intensity >= 0.6) {
18 | return "Z2";
19 | }
20 | return "Z1";
21 | };
22 |
--------------------------------------------------------------------------------
/src/ast.ts:
--------------------------------------------------------------------------------
1 | import { IntervalType, SourceLocation } from "./parser/tokenizer";
2 | import { Duration } from "./Duration";
3 | import { Intensity } from "./Intensity";
4 |
5 | export type Workout = {
6 | name: string;
7 | author: string;
8 | description: string;
9 | tags: string[];
10 | intervals: Interval[];
11 | };
12 |
13 | export type Interval = {
14 | type: IntervalType;
15 | duration: Duration;
16 | intensity: Intensity;
17 | cadence?: number;
18 | comments: Comment[];
19 | };
20 |
21 | export type Comment = {
22 | offset: Duration;
23 | text: string;
24 | loc: SourceLocation;
25 | };
26 |
--------------------------------------------------------------------------------
/src/stats/intervalsToIntensityNumbers.ts:
--------------------------------------------------------------------------------
1 | import { chain } from "ramda";
2 | import { Interval } from "../ast";
3 |
4 | // Converts interval to array of intensity values for each second
5 | const intervalToIntensityNumbers = ({ duration, intensity }: Interval): number[] => {
6 | const intensities: number[] = [];
7 | const [from, to] = [intensity.start, intensity.end];
8 | for (let i = 0; i < duration.seconds; i++) {
9 | // Intensity in a single second
10 | intensities.push(from + (to - from) * (i / duration.seconds));
11 | }
12 | return intensities;
13 | };
14 |
15 | export const intervalsToIntensityNumbers = chain(intervalToIntensityNumbers);
16 |
--------------------------------------------------------------------------------
/src/parseCliOptions.ts:
--------------------------------------------------------------------------------
1 | import { ArgumentParser } from "argparse";
2 |
3 | export type CliOptions = {
4 | file: string;
5 | stats: boolean;
6 | };
7 |
8 | export const parseCliOptions = (): CliOptions => {
9 | const argParser = new ArgumentParser({
10 | description: "Zwift workout generator",
11 | add_help: true,
12 | });
13 |
14 | argParser.add_argument("--stats", {
15 | help: "output aggregate statistics instead of ZWO file",
16 | action: "store_true",
17 | default: false,
18 | });
19 |
20 | argParser.add_argument("file", {
21 | nargs: "?",
22 | default: 0, // Default to reading STDIN
23 | });
24 |
25 | return argParser.parse_args();
26 | };
27 |
--------------------------------------------------------------------------------
/src/stats/tss.ts:
--------------------------------------------------------------------------------
1 | import { Duration } from "../Duration";
2 | import { Intensity } from "../Intensity";
3 |
4 | // Training Stress Score formula from Training and Racing with a Power Meter:
5 | //
6 | // TSS = (s * W * IF) / (FTP * 3600) * 100
7 | //
8 | // s - duration in seconds
9 | // W - power in watts
10 | // IF - intensity factor (power / FTP)
11 | //
12 | // Derive a formula without power values, using intensities alone:
13 | //
14 | // TSS = (s * (FTP * IF) * IF) / (FTP * 3600) * 100
15 | // TSS = (s * IF * IF) / 3600 * 100
16 |
17 | export const tss = (duration: Duration, intensity: Intensity): number => {
18 | return ((duration.seconds * Math.pow(intensity.value, 2)) / 3600) * 100;
19 | };
20 |
--------------------------------------------------------------------------------
/examples/darth-vader.txt:
--------------------------------------------------------------------------------
1 | Name: Darth Vader
2 | Author: HumanPowerPerformance.com
3 | Description:
4 | Sign up for coaching with HumanPowerPerformance.com and get custom workouts and training plans
5 |
6 | Warmup: 5:00 55%
7 |
8 | Rest: 3:00 82%
9 | Interval: 00:20 130%
10 | Rest: 3:00 82%
11 | Interval: 00:20 130%
12 | Rest: 3:00 82%
13 | Interval: 00:20 130%
14 | Rest: 3:00 82%
15 | Interval: 00:20 130%
16 | Rest: 3:00 82%
17 | Interval: 00:20 130%
18 | Rest: 3:00 82%
19 | Interval: 00:20 130%
20 | Rest: 3:00 82%
21 | Interval: 00:20 130%
22 | Rest: 3:00 82%
23 | Interval: 00:20 130%
24 | Rest: 3:00 82%
25 | Interval: 00:20 130%
26 | Rest: 3:00 82%
27 | Interval: 00:20 130%
28 |
29 | Cooldown: 5:00 55%
30 |
--------------------------------------------------------------------------------
/examples/threshold-pushing.txt:
--------------------------------------------------------------------------------
1 | Name: Threshold pushing
2 | Author: R.Saarsoo
3 | Description:
4 | Start with a good warm up (10 minutes Zone 1, then 5 minutes Zone 2,
5 | 5 minutes Zone 3 followed by 5 minutes easy spinning).
6 | Then straight in at Threshold Pushing powers for 12 mins.
7 | Have 10 mins easy soft pedalling and repeat - warm down 15 minutes.
8 | This session is designed to increase your FTP from below.
9 | Working predominately aerobic metabolism.
10 | In time your body will become more comfortable at these powers
11 | and your FTP will increase.
12 |
13 | Warmup: 20:00 45%..75%
14 |
15 | Rest: 5:00 52%
16 | Interval: 12:00 100% 90rpm
17 | Rest: 10:00 52%
18 | Interval: 12:00 100% 90rpm
19 | Rest: 10:00 52%
20 |
21 | Cooldown: 10:00 65%..45%
22 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { generateZwo } from "./generateZwo";
2 | export { parse } from "./parser";
3 | export { stats, formatStats } from "./stats";
4 | export { parseCliOptions } from "./parseCliOptions";
5 |
6 | // types
7 | export { Workout, Interval, Comment } from "./ast";
8 | export { Duration } from "./Duration";
9 | export { Intensity, ConstantIntensity, RangeIntensity, FreeIntensity } from "./Intensity";
10 | export { ZoneType, intensityValueToZoneType } from "./ZoneType";
11 | export { SourceLocation } from "./parser/tokenizer";
12 | import { ParseError } from "./parser/ParseError";
13 | import { ValidationError } from "./parser/ValidationError";
14 | export type ZwiftoutException = ParseError | ValidationError;
15 | export { ParseError, ValidationError };
16 |
17 | // utils
18 | export { totalDuration } from "./stats/totalDuration";
19 | export { maximumIntensity } from "./stats/maximumIntensity";
20 |
--------------------------------------------------------------------------------
/test/cli.test.ts:
--------------------------------------------------------------------------------
1 | import * as fs from "fs";
2 | import { generateZwo } from "../src/generateZwo";
3 | import { parse } from "../src/parser";
4 | import { formatStats, stats } from "../src/stats";
5 |
6 | const createStats = (filename: string) => formatStats(stats(parse(fs.readFileSync(filename, "utf8"))));
7 | const createZwo = (filename: string) => generateZwo(parse(fs.readFileSync(filename, "utf8")));
8 |
9 | const filenames = [
10 | "examples/comments.txt",
11 | "examples/darth-vader.txt",
12 | "examples/ftp-test.txt",
13 | "examples/halvfems.txt",
14 | "examples/threshold-pushing.txt",
15 | "examples/ramps.txt",
16 | ];
17 |
18 | describe("Generate ZWO", () => {
19 | filenames.forEach((filename) => {
20 | it(filename, () => {
21 | expect(createZwo(filename)).toMatchSnapshot();
22 | });
23 | });
24 | });
25 |
26 | describe("Generate stats", () => {
27 | filenames.forEach((filename) => {
28 | it(filename, () => {
29 | expect(createStats(filename)).toMatchSnapshot();
30 | });
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/examples/comments.txt:
--------------------------------------------------------------------------------
1 | Name: Workout with comments
2 |
3 | Warmup: 10:00 25%..75%
4 | @ 0:10 Welcome to: #8
5 | @ 6:00 Let's talk about today's workout
6 | @ 6:10 We'll be doing 5 blocks of VO2max work
7 | @ 6:20 Each block 2 minutes and 30 seconds
8 | @ 6:30 30 seconds more than last time :)
9 | @ 6:40 Finally we finish off with 10 minutes of threshold work
10 | @ 6:50 Easy as that :)
11 |
12 | Interval: 0:30 95% 95rpm
13 | @ 0:00 Spin to 95 RPM if you're not already there
14 |
15 | Rest: 0:30 50% 85rpm
16 | @ 0:00 Rest a bit
17 |
18 | Interval: 2:30 115% 105rpm
19 | @ 0:00 Here we go!
20 | @ 1:00 First minute done
21 | @ 2:00 Just 30 seconds more
22 | @ 2:20 Finish it!
23 |
24 | Rest: 3:00 50% 85rpm
25 | @ 0:00 Good!
26 | @ 0:10 Rest for 3 minutes
27 | @ 2:50 Ready for next round?
28 |
29 | Cooldown: 7:30 90%..25%
30 | @ 0:00 We'll now gradually decrease the power
31 | @ 0:10 It's all downhill from here :)
32 | @ 0:20 until you're cooled down
33 | @ 7:10 Thanks for participating
34 | @ 7:20 Until next time!
35 |
36 |
--------------------------------------------------------------------------------
/examples/threshold-pushing.zwo:
--------------------------------------------------------------------------------
1 |
2 | R.Saarsoo
3 | Threshold pushing
4 |
5 | Start with a good warm up (10 minutes Zone 1, then 5 minutes Zone 2,
6 | 5 minutes Zone 3 followed by 5 minutes easy spinning).
7 | Then straight in at Threshold Pushing powers for 12 mins.
8 | Have 10 mins easy soft pedalling and repeat - warm down 15 minutes.
9 | This session is designed to increase your FTP from below.
10 | Working predominately aerobic metabolism.
11 | In time your body will become more comfortable at these powers
12 | and your FTP will increase.
13 |
14 | bike
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/parser/validate.ts:
--------------------------------------------------------------------------------
1 | import { Workout, Interval } from "../ast";
2 | import { ValidationError } from "./ValidationError";
3 |
4 | const validateCommentOffsets = ({ comments, duration }: Interval) => {
5 | for (let i = 0; i < comments.length; i++) {
6 | const comment = comments[i];
7 | if (comment.offset.seconds >= duration.seconds) {
8 | throw new ValidationError(`Comment offset is larger than interval length`, comment.loc);
9 | }
10 | if (comment.offset.seconds < 0) {
11 | throw new ValidationError(`Negative comment offset is larger than interval length`, comment.loc);
12 | }
13 | if (i > 0 && comment.offset.seconds <= comments[i - 1].offset.seconds) {
14 | throw new ValidationError(`Comment overlaps previous comment`, comment.loc);
15 | }
16 | if (i > 0 && comment.offset.seconds < comments[i - 1].offset.seconds + 10) {
17 | throw new ValidationError(`Less than 10 seconds between comments`, comment.loc);
18 | }
19 | if (comment.offset.seconds + 10 > duration.seconds) {
20 | throw new ValidationError(`Less than 10 seconds between comment start and interval end`, comment.loc);
21 | }
22 | }
23 | };
24 |
25 | export const validate = (workout: Workout): Workout => {
26 | workout.intervals.forEach(validateCommentOffsets);
27 | return workout;
28 | };
29 |
--------------------------------------------------------------------------------
/src/stats/xp.ts:
--------------------------------------------------------------------------------
1 | import { Interval } from "../ast";
2 | import { sum } from "ramda";
3 | import { RangeIntensity, ConstantIntensity, FreeIntensity } from "../Intensity";
4 | import { RepeatedInterval } from "../detectRepeats";
5 | import { totalDuration } from "./totalDuration";
6 |
7 | const intervalXp = (interval: Interval | RepeatedInterval): number => {
8 | if (interval.type === "repeat") {
9 | // 11.9 XP per minute (1 XP for every 5.05 seconds)
10 | const duration = totalDuration(interval.intervals).seconds * interval.times;
11 | return Math.floor(duration / 5.05); // Suitable numbers are: 5.01 .. 5.09
12 | } else {
13 | if (interval.intensity instanceof ConstantIntensity) {
14 | // 10.8 XP per minute (1XP for every 5.56 seconds)
15 | return Math.floor(interval.duration.seconds / 5.56);
16 | } else if (interval.intensity instanceof RangeIntensity) {
17 | // 6 XP per minute (1XP for every 10 seconds)
18 | return Math.floor(interval.duration.seconds / 10);
19 | } else if (interval.intensity instanceof FreeIntensity) {
20 | // 5.9 XP per minute (1XP for every 10.1 seconds)
21 | return Math.floor(interval.duration.seconds / 10.1);
22 | } else {
23 | throw new Error("Unknown type of intensity");
24 | }
25 | }
26 | };
27 |
28 | export const xp = (intervals: (Interval | RepeatedInterval)[]): number => sum(intervals.map(intervalXp));
29 |
--------------------------------------------------------------------------------
/src/stats/zoneDistribution.ts:
--------------------------------------------------------------------------------
1 | import { Interval } from "../ast";
2 | import { Duration } from "../Duration";
3 | import { intensityValueToZoneType, ZoneType } from "../ZoneType";
4 | import { intervalsToIntensityNumbers } from "./intervalsToIntensityNumbers";
5 |
6 | type NumericZoneDuration = { name: string; duration: number };
7 | export type ZoneDuration = { name: string; duration: Duration };
8 |
9 | const emptyZones = (): Record => ({
10 | Z1: { name: "Z1: Recovery", duration: 0 },
11 | Z2: { name: "Z2: Endurance", duration: 0 },
12 | Z3: { name: "Z3: Tempo", duration: 0 },
13 | Z4: { name: "Z4: Threshold", duration: 0 },
14 | Z5: { name: "Z5: VO2 Max", duration: 0 },
15 | Z6: { name: "Z6: Anaerobic", duration: 0 },
16 | free: { name: "Freeride", duration: 0 },
17 | });
18 |
19 | export const zoneDistribution = (intervals: Interval[]): ZoneDuration[] => {
20 | const zones = emptyZones();
21 |
22 | intervals.forEach((interval) => {
23 | if (interval.intensity.start === interval.intensity.end) {
24 | zones[interval.intensity.zone].duration += interval.duration.seconds;
25 | } else {
26 | intervalsToIntensityNumbers([interval]).forEach((intensityValue) => {
27 | zones[intensityValueToZoneType(intensityValue)].duration++;
28 | });
29 | }
30 | });
31 |
32 | return Object.values(zones).map(({ duration, ...rest }) => ({ duration: new Duration(duration), ...rest }));
33 | };
34 |
--------------------------------------------------------------------------------
/src/stats/normalizedIntensity.ts:
--------------------------------------------------------------------------------
1 | import { map, pipe, sum } from "ramda";
2 | import { Interval } from "../ast";
3 | import { ConstantIntensity } from "../Intensity";
4 | import { average } from "./average";
5 | import { intervalsToIntensityNumbers } from "./intervalsToIntensityNumbers";
6 |
7 | // Starting at the beginning of the data, calculate 30-second rolling average
8 | const windowSize = 30; // equals to nr of seconds, but also to nr of entries in intensities array
9 | const rollingAverages = (intensities: number[]): number[] => {
10 | let rollingSum: number = sum(intensities.slice(0, windowSize));
11 |
12 | if (intensities.length === 0) {
13 | return [0];
14 | }
15 | if (intensities.length < windowSize) {
16 | return [rollingSum / intensities.length];
17 | }
18 |
19 | const averages: number[] = [];
20 | averages.push(rollingSum / windowSize);
21 | for (let i = 0; i < intensities.length - windowSize; i++) {
22 | rollingSum -= intensities[i];
23 | rollingSum += intensities[i + windowSize];
24 | averages.push(rollingSum / windowSize);
25 | }
26 | return averages;
27 | };
28 |
29 | const fourthPower = (x: number) => Math.pow(x, 4);
30 |
31 | const fourthRoot = (x: number) => Math.pow(x, 1 / 4);
32 |
33 | export const normalizedIntensity = (intervals: Interval[]): ConstantIntensity => {
34 | return new ConstantIntensity(
35 | pipe(intervalsToIntensityNumbers, rollingAverages, map(fourthPower), average, fourthRoot)(intervals),
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/examples/ftp-test.txt:
--------------------------------------------------------------------------------
1 | Author: Zwift
2 | Name: FTP Test (shorter)
3 | Description:
4 | The short variation of the standard FTP test starts off with a short warmup,
5 | a quick leg opening ramp, and a 5 minute hard effort to get the legs pumping.
6 | After a brief rest it's time to give it your all and go as hard as you can for 20 solid minutes.
7 | Pace yourself and try to go as hard as you can sustain for the entire 20 minutes -
8 | you will be scored on the final 20 minute segment.
9 |
10 | Upon saving your ride, you will be notified if your FTP improved.
11 | Tags: FTP, Test
12 |
13 | Warmup: 5:00 30%..70%
14 | @ 00:20 Welcome to the FTP test
15 | @ 00:35 Time to get warmed up and get your cadence up to 90-100rpm
16 |
17 | Interval: 00:20 90%
18 | Interval: 00:20 110%
19 | Interval: 00:20 130%
20 |
21 | Interval: 03:00 60%
22 | Interval: 03:00 110%
23 | Interval: 02:00 120%
24 |
25 | Rest: 06:00 55%
26 | @ 00:10 In 6 minutes the FTP test begins
27 | @ 05:00 Prepare for your max 20 minute effort
28 |
29 | FreeRide: 20:00
30 | @ 00:10 Bring your power up to what you think you can hold for 20 minutes
31 | @ 05:00 How ya doin?
32 | @ 10:00 Half way done! If it's feeling easy now might be time to add 10 watts.
33 | @ 15:00 5 minutes left!
34 | @ 16:00 4 minutes left. Increase your power if you're feeling strong.
35 | @ 17:00 3 minutes left
36 | @ 18:00 2 minutes left
37 | @ 19:00 1 minute left. Go all in!
38 | @ 19:30 30 sec. Go for it.
39 | @ 19:50 10 seconds.
40 |
41 | Cooldown: 5:00 50%..30%
42 |
43 |
--------------------------------------------------------------------------------
/src/parser/fillRangeIntensities.ts:
--------------------------------------------------------------------------------
1 | import { Interval, Workout } from "../ast";
2 | import { FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
3 |
4 | const fillIntensities = (prevInterval: Interval, interval: Interval): Interval => {
5 | if (!(interval.intensity instanceof RangeIntensityEnd)) {
6 | return interval;
7 | }
8 |
9 | if (prevInterval.intensity instanceof FreeIntensity) {
10 | throw new Error("range-intensity-end interval can't be after free-intensity interval");
11 | }
12 |
13 | return {
14 | ...interval,
15 | intensity: new RangeIntensity(prevInterval.intensity.end, interval.intensity.end),
16 | };
17 | };
18 |
19 | // Given: [1, 2, 3, 4]
20 | // Returns: [[1,2], [2,3], [3,4]]
21 | const pairs = (arr: T[]): T[][] => {
22 | const result: T[][] = [];
23 | for (let i = 1; i < arr.length; i++) {
24 | result.push([arr[i - 1], arr[i]]);
25 | }
26 | return result;
27 | };
28 |
29 | const fillIntensitiesInIntervals = (intervals: Interval[]): Interval[] => {
30 | if (intervals.length <= 1) {
31 | if (intervals.length === 1 && intervals[0].intensity instanceof RangeIntensityEnd) {
32 | throw new Error("range-intensity-end interval can't be the first interval");
33 | }
34 | return intervals;
35 | }
36 |
37 | return [intervals[0], ...pairs(intervals).map(([prev, curr]) => fillIntensities(prev, curr))];
38 | };
39 |
40 | export const fillRangeIntensities = (workout: Workout): Workout => {
41 | return {
42 | ...workout,
43 | intervals: fillIntensitiesInIntervals(workout.intervals),
44 | };
45 | };
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zwiftout",
3 | "version": "2.3.0",
4 | "license": "GPL-3.0-or-later",
5 | "description": "Zwift workout generator command line tool and library",
6 | "author": "Rene Saarsoo ",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/nene/zwiftout.git"
10 | },
11 | "scripts": {
12 | "lint:ts": "tsc --noEmit",
13 | "lint:js": "eslint 'src/**/*'",
14 | "test": "jest src/ test/",
15 | "test:watch": "jest src/ test/ --watch",
16 | "start": "node bin/zwiftout.js",
17 | "format:js": "prettier --write src/",
18 | "build": "tsc --project tsconfig.release.json",
19 | "prepublish": "yarn build"
20 | },
21 | "main": "dist/index.js",
22 | "types": "dist/index.d.ts",
23 | "bin": {
24 | "zwiftout": "bin/zwiftout.js"
25 | },
26 | "dependencies": {
27 | "argparse": "^2.0.1",
28 | "ramda": "^0.27.1",
29 | "xml": "^1.0.1"
30 | },
31 | "devDependencies": {
32 | "@types/argparse": "^2.0.0",
33 | "@types/jest": "^26.0.14",
34 | "@types/node": "^14.10.3",
35 | "@types/ramda": "types/npm-ramda#dist",
36 | "@types/xml": "^1.0.5",
37 | "@typescript-eslint/eslint-plugin": "^4.1.1",
38 | "@typescript-eslint/parser": "^4.1.1",
39 | "eslint": "^7.9.0",
40 | "eslint-config-prettier": "^6.11.0",
41 | "husky": "^4.3.0",
42 | "jest": "^26.4.2",
43 | "lint-staged": "^10.4.0",
44 | "prettier": "^2.1.2",
45 | "ts-jest": "^26.4.0",
46 | "ts-node": "^9.0.0",
47 | "typescript": "^4.0.2"
48 | },
49 | "lint-staged": {
50 | "src/**/*.ts": [
51 | "prettier --write --",
52 | "eslint --fix"
53 | ]
54 | },
55 | "husky": {
56 | "hooks": {
57 | "pre-commit": "lint-staged"
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/stats/index.ts:
--------------------------------------------------------------------------------
1 | import { Workout } from "../ast";
2 | import { detectRepeats } from "../detectRepeats";
3 | import { Duration } from "../Duration";
4 | import { Intensity } from "../Intensity";
5 | import { averageIntensity } from "./averageIntensity";
6 | import { normalizedIntensity } from "./normalizedIntensity";
7 | import { totalDuration } from "./totalDuration";
8 | import { tss } from "./tss";
9 | import { xp } from "./xp";
10 | import { zoneDistribution, ZoneDuration } from "./zoneDistribution";
11 |
12 | export type Stats = {
13 | totalDuration: Duration;
14 | averageIntensity: Intensity;
15 | normalizedIntensity: Intensity;
16 | tss: number;
17 | xp: number;
18 | zones: ZoneDuration[];
19 | };
20 |
21 | // Generates statistics
22 | export const stats = ({ intervals }: Workout): Stats => {
23 | const duration = totalDuration(intervals);
24 | const normIntensity = normalizedIntensity(intervals);
25 | return {
26 | totalDuration: totalDuration(intervals),
27 | averageIntensity: averageIntensity(intervals),
28 | normalizedIntensity: normalizedIntensity(intervals),
29 | tss: tss(duration, normIntensity),
30 | xp: xp(detectRepeats(intervals)),
31 | zones: zoneDistribution(intervals),
32 | };
33 | };
34 |
35 | export const formatStats = ({ totalDuration, averageIntensity, normalizedIntensity, tss, xp, zones }: Stats) => {
36 | return `
37 | Total duration: ${(totalDuration.seconds / 60).toFixed()} minutes
38 |
39 | Average intensity: ${(averageIntensity.value * 100).toFixed()}%
40 | Normalized intensity: ${(normalizedIntensity.value * 100).toFixed()}%
41 |
42 | TSS: ${tss.toFixed()}
43 | XP: ${xp}
44 |
45 | Zone Distribution:
46 | ${zones.map(({ name, duration }) => `${(duration.seconds / 60).toFixed().padStart(3)} min - ${name}`).join("\n")}
47 | `;
48 | };
49 |
--------------------------------------------------------------------------------
/src/Intensity.ts:
--------------------------------------------------------------------------------
1 | import { intensityValueToZoneType, ZoneType } from "./ZoneType";
2 |
3 | export interface Intensity {
4 | readonly value: number;
5 | readonly start: number;
6 | readonly end: number;
7 | readonly zone: ZoneType;
8 | }
9 |
10 | export class ConstantIntensity implements Intensity {
11 | constructor(private _value: number) {}
12 |
13 | get value() {
14 | return this._value;
15 | }
16 |
17 | get start() {
18 | return this._value;
19 | }
20 |
21 | get end() {
22 | return this._value;
23 | }
24 |
25 | get zone() {
26 | return intensityValueToZoneType(this._value);
27 | }
28 | }
29 |
30 | export class RangeIntensity implements Intensity {
31 | constructor(private _start: number, private _end: number) {}
32 |
33 | get value() {
34 | return this._start;
35 | }
36 |
37 | get start() {
38 | return this._start;
39 | }
40 |
41 | get end() {
42 | return this._end;
43 | }
44 |
45 | get zone() {
46 | return intensityValueToZoneType(this.value);
47 | }
48 | }
49 |
50 | export class RangeIntensityEnd implements Intensity {
51 | constructor(private _end: number) {}
52 |
53 | get value() {
54 | return this._end;
55 | }
56 |
57 | get start(): number {
58 | throw new Error("RangeIntensityEnd has no start");
59 | }
60 |
61 | get end() {
62 | return this._end;
63 | }
64 |
65 | get zone() {
66 | return intensityValueToZoneType(this.value);
67 | }
68 | }
69 |
70 | export class FreeIntensity implements Intensity {
71 | get value() {
72 | // To match Zwift, which gives 64 TSS for 1h of freeride.
73 | return 0.8;
74 | }
75 |
76 | get start() {
77 | return this.value;
78 | }
79 |
80 | get end() {
81 | return this.value;
82 | }
83 |
84 | get zone() {
85 | return "free" as ZoneType;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/examples/halvfems.txt:
--------------------------------------------------------------------------------
1 | Name: Halvfems
2 | Description:
3 | Named after the number 90 in Danish, this workout is focused sweet spot training, centered around 90% of FTP.
4 | This pairs with Devedeset (90 in Croatian) and Novanta (90 in Italian) to make up the sweet spot trifecta in this plan.
5 | The workouts are alphabetically ordered from easiest to hardest, so enjoy the middle of these three challenging workouts today...
6 | Tags: Intervals
7 |
8 | Warmup: 7:00 25%..75%
9 | Interval: 00:30 95rpm 95%
10 | Rest: 00:30 85rpm 50%
11 |
12 | Interval: 00:30 105rpm 105%
13 | Rest: 00:30 85rpm 50%
14 |
15 | Interval: 00:30 115rpm 115%
16 | Rest: 00:30 85rpm 50%
17 |
18 | Rest: 2:00 85rpm 50%
19 |
20 | Interval: 12:00 90rpm 90%
21 |
22 | Rest: 4:00 85rpm 55%
23 |
24 | Interval: 1:00 60rpm 90%
25 | @ 00:00 Start off at slow cadence
26 | Interval: 1:00 90rpm 90%
27 | @ 00:00 Now bump that cadence up
28 | Interval: 1:00 60rpm 90%
29 | @ 00:00 Back to 60 RPM
30 | Interval: 1:00 90rpm 90%
31 | @ 00:00 Back to normal cadence
32 | Interval: 1:00 60rpm 90%
33 | @ 00:00 Grinding...
34 | Interval: 1:00 90rpm 90%
35 | @ 00:00 Back to normal cadence
36 | Interval: 1:00 60rpm 90%
37 | @ 00:00 And grind at 60 RPM again
38 | Interval: 1:00 90rpm 90%
39 | @ 00:00 Back to 90 RPM
40 | Interval: 1:00 60rpm 90%
41 | @ 00:00 Lower it again to 60 RPM
42 | Interval: 1:00 90rpm 90%
43 | @ 00:00 Increase the cadence to 90 RPM
44 | Interval: 1:00 60rpm 90%
45 | @ 00:00 Last grinding minute
46 | Interval: 1:00 90rpm 90%
47 | @ 00:00 Last minute... be strong!
48 |
49 | Rest: 4:00 85rpm 55%
50 |
51 | Interval: 2:00 100rpm 90%
52 | Interval: 1:00 65rpm 90%
53 | Interval: 2:00 100rpm 90%
54 | Interval: 1:00 65rpm 90%
55 | Interval: 2:00 100rpm 90%
56 | Interval: 1:00 65rpm 90%
57 | Interval: 2:00 100rpm 90%
58 | Interval: 1:00 65rpm 90%
59 |
60 | Cooldown: 6:00 55%..25%
61 |
--------------------------------------------------------------------------------
/src/stats/normalizedIntensity.test.ts:
--------------------------------------------------------------------------------
1 | import { Interval } from "../ast";
2 | import { Duration } from "../Duration";
3 | import { ConstantIntensity, RangeIntensity } from "../Intensity";
4 | import { normalizedIntensity } from "./normalizedIntensity";
5 |
6 | const round = (intensity: ConstantIntensity) => Math.round(intensity.value * 100) / 100;
7 |
8 | describe("normalizedIntensity()", () => {
9 | it("when no intervals, returns 0 for all zones", () => {
10 | expect(round(normalizedIntensity([]))).toEqual(0);
11 | });
12 |
13 | it("returns plain average for < 30-seconds workout", () => {
14 | expect(
15 | round(
16 | normalizedIntensity([
17 | {
18 | type: "Interval",
19 | duration: new Duration(10),
20 | intensity: new ConstantIntensity(0.5),
21 | comments: [],
22 | },
23 | {
24 | type: "Interval",
25 | duration: new Duration(10),
26 | intensity: new ConstantIntensity(0.7),
27 | comments: [],
28 | },
29 | ]),
30 | ),
31 | ).toEqual(0.6);
32 | });
33 |
34 | it("is the intensity of an interval for single-interval workout", () => {
35 | const intervals: Interval[] = [
36 | {
37 | type: "Interval",
38 | duration: new Duration(20 * 60),
39 | intensity: new ConstantIntensity(0.75),
40 | comments: [],
41 | },
42 | ];
43 | expect(round(normalizedIntensity(intervals))).toEqual(0.75);
44 | });
45 |
46 | it("leans towards higher intensity for two-interval workout", () => {
47 | const intervals: Interval[] = [
48 | {
49 | type: "Interval",
50 | duration: new Duration(10 * 60),
51 | intensity: new ConstantIntensity(0.5),
52 | comments: [],
53 | },
54 | {
55 | type: "Interval",
56 | duration: new Duration(10 * 60),
57 | intensity: new ConstantIntensity(1.0),
58 | comments: [],
59 | },
60 | ];
61 | expect(round(normalizedIntensity(intervals))).toEqual(0.85);
62 | });
63 |
64 | it("handles range-intensities", () => {
65 | const intervals: Interval[] = [
66 | {
67 | type: "Warmup",
68 | duration: new Duration(20 * 60),
69 | intensity: new RangeIntensity(0.5, 1.0),
70 | comments: [],
71 | },
72 | ];
73 | expect(round(normalizedIntensity(intervals))).toEqual(0.79);
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/examples/ftp-test.zwo:
--------------------------------------------------------------------------------
1 |
2 | Zwift
3 | FTP Test (shorter)
4 | The short variation of the standard FTP test starts off with a short warmup, a quick leg opening ramp, and a 5 minute hard effort to get the legs pumping. After a brief rest it's time to give it your all and go as hard as you can for 20 solid minutes. 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.
5 |
6 | Upon saving your ride, you will be notified if your FTP improved.
7 | bike
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/detectRepeats.ts:
--------------------------------------------------------------------------------
1 | import { eqProps, flatten, zip } from "ramda";
2 | import { Interval, Comment } from "./ast";
3 | import { Duration } from "./Duration";
4 |
5 | export type RepeatedInterval = {
6 | type: "repeat";
7 | times: number;
8 | intervals: Interval[];
9 | comments: Comment[];
10 | };
11 |
12 | // All fields besides comments must equal
13 | const equalIntervals = (a: Interval, b: Interval): boolean =>
14 | eqProps("type", a, b) && eqProps("duration", a, b) && eqProps("intensity", a, b) && eqProps("cadence", a, b);
15 |
16 | const equalIntervalArrays = (as: Interval[], bs: Interval[]): boolean =>
17 | zip(as, bs).every(([a, b]) => equalIntervals(a, b));
18 |
19 | const windowSize = 2;
20 |
21 | const countRepetitions = (reference: Interval[], intervals: Interval[], startIndex: number): number => {
22 | let repeats = 1;
23 | while (startIndex + repeats * windowSize < intervals.length) {
24 | const from = startIndex + repeats * windowSize;
25 | const possibleRepeat = intervals.slice(from, from + windowSize);
26 | if (equalIntervalArrays(reference, possibleRepeat)) {
27 | repeats++;
28 | } else {
29 | return repeats;
30 | }
31 | }
32 | return repeats;
33 | };
34 |
35 | const offsetComments = (interval: Interval, baseOffset: Duration): Comment[] => {
36 | return interval.comments.map(({ offset, ...rest }) => ({
37 | offset: baseOffset.add(offset),
38 | ...rest,
39 | }));
40 | };
41 |
42 | const collectComments = (intervals: Interval[]): Comment[] => {
43 | let previousIntervalsDuration = new Duration(0);
44 | return flatten(
45 | intervals.map((interval) => {
46 | const comments = offsetComments(interval, previousIntervalsDuration);
47 | previousIntervalsDuration = previousIntervalsDuration.add(interval.duration);
48 | return comments;
49 | }),
50 | );
51 | };
52 |
53 | const stripComments = (intervals: Interval[]): Interval[] => {
54 | return intervals.map(({ comments, ...rest }) => ({ comments: [], ...rest }));
55 | };
56 |
57 | const extractRepeatedInterval = (intervals: Interval[], i: number): RepeatedInterval | undefined => {
58 | const reference = intervals.slice(i, i + windowSize);
59 | const repeats = countRepetitions(reference, intervals, i);
60 | if (repeats === 1) {
61 | return undefined;
62 | }
63 |
64 | return {
65 | type: "repeat",
66 | times: repeats,
67 | intervals: stripComments(reference),
68 | comments: collectComments(intervals.slice(i, i + windowSize * repeats)),
69 | };
70 | };
71 |
72 | const isRangeInterval = ({ intensity }: Interval): boolean => intensity.start !== intensity.end;
73 |
74 | export const detectRepeats = (intervals: Interval[]): (Interval | RepeatedInterval)[] => {
75 | if (intervals.length < windowSize) {
76 | return intervals;
77 | }
78 |
79 | const processed: (Interval | RepeatedInterval)[] = [];
80 | let i = 0;
81 | while (i < intervals.length) {
82 | // Ignore warmup/cooldown-range intervals
83 | if (isRangeInterval(intervals[i])) {
84 | processed.push(intervals[i]);
85 | i++;
86 | continue;
87 | }
88 |
89 | const repeatedInterval = extractRepeatedInterval(intervals, i);
90 | if (repeatedInterval) {
91 | processed.push(repeatedInterval);
92 | i += repeatedInterval.times * windowSize;
93 | } else {
94 | processed.push(intervals[i]);
95 | i++;
96 | }
97 | }
98 |
99 | return processed;
100 | };
101 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Zwiftout
2 |
3 | [Zwift][] workout generator command line tool and library.
4 |
5 | Used as engine for the online [workout-editor][].
6 |
7 | ## Motivation
8 |
9 | Creating custom workouts is a pain.
10 |
11 | The workout editor in Zwift is pretty clumsy and slow to use:
12 |
13 | - drag'n'drop is not the fastest way to edit,
14 | - positioning of messages is especially tricky as they easily jump out of place,
15 | - there's no copy-paste functionality to speed things up.
16 |
17 | Editing .zwo files by hand is also inconvenient:
18 |
19 | - you'll have to constantly convert minutes to seconds,
20 | - you can easily make errors in XML syntax, rendering the file invalid,
21 | - [it's really a bad format.][zwo-sucks]
22 |
23 | There are a few alternative editors online:
24 |
25 | - [ZWOFactory][] is very much point'n'click based which doesn't help much with speeding up the process.
26 | - [Simple ZWO Creator][] is more akin to my liking as it's text-based.
27 | But it's lacking lots of features: no way to add cadence, text messages, warmup/cooldown etc.
28 | The syntax used is also kinda confusing.
29 |
30 | ## Features
31 |
32 | - Fully text-based workout creation.
33 | - Easily add cadence targets.
34 | - Easily place text-messages within intervals.
35 | - Support for Warmup/Cooldown & FreeRide interval types.
36 | - Automatic detection of repeated intervals - conversion to `` in .zwo file.
37 | - Generation of stats: average and normalized intensity, TSS, zone-distribution.
38 |
39 | ## Install
40 |
41 | ```
42 | $ npm install -g zwiftout
43 | ```
44 |
45 | ## Usage
46 |
47 | Write a workout description like:
48 |
49 | ```
50 | Name: Sample workout
51 | Author: John Doe
52 | Tags: Recovery, Intervals, FTP
53 | Description: Try changing it, and see what happens below.
54 |
55 | Warmup: 10:00 30%..75%
56 | Interval: 15:00 100% 90rpm
57 | @ 00:00 Start off easy
58 | @ 01:00 Settle into rhythm
59 | @ 07:30 Half way through
60 | @ 14:00 Final minute, stay strong!
61 | Rest: 10:00 75%
62 | FreeRide: 20:00
63 | @ 00:10 Just have some fun, riding as you wish
64 | Cooldown: 10:00 70%..30%
65 | ```
66 |
67 | Feed that file into `zwiftout` program, which spits out Zwift workout XML:
68 |
69 | ```
70 | $ zwiftout my-workout.txt > my-workout.zwo
71 | ```
72 |
73 | Also, you can query various stats about the workout:
74 |
75 | ```
76 | $ zwiftout --stats my-workout.txt
77 |
78 | Total duration: 65 minutes
79 |
80 | Average intensity: 50%
81 | Normalized intensity: 75%
82 |
83 | TSS: 60
84 |
85 | Zone Distribution:
86 | 14 min - Z1: Recovery
87 | 6 min - Z2: Endurance
88 | 10 min - Z3: Tempo
89 | 15 min - Z4: Threshold
90 | 0 min - Z5: VO2 Max
91 | 0 min - Z6: Anaerobic
92 | 20 min - Freeride
93 | ```
94 |
95 | ## Usage as library
96 |
97 | ```js
98 | import { parse, generateZwo, stats } from "zwiftout";
99 |
100 | const workout = parse(`
101 | Name: Sample workout
102 | Warmup: 10:00 30%..75%
103 | Interval: 15:00 100% 90rpm
104 | `);
105 |
106 | // Output ZWO file
107 | console.log(generateZwo(workout));
108 |
109 | // Output various statistics
110 | console.log(stats(workout));
111 | ```
112 |
113 | ## TODO
114 |
115 | - Repeats (and nested repeats)
116 | - Unsupported params: message duration & y-position
117 | - More restricted syntax for text (with quotes)
118 | - Concatenate similar intervals
119 | - Distinguish between terrain-sensitive and insensitive free-ride.
120 |
121 | [zwift]: https://zwift.com/
122 | [zwofactory]: https://zwofactory.com/
123 | [simple zwo creator]: https://zwifthacks.com/app/simple-zwo-creator/
124 | [workout-editor]: https://nene.github.io/workout-editor/
125 | [zwo-sucks]: http://nene.github.io/2021/01/14/zwo-sucks
126 |
--------------------------------------------------------------------------------
/src/generateZwo.ts:
--------------------------------------------------------------------------------
1 | import * as xml from "xml";
2 | import { Interval, Workout, Comment } from "./ast";
3 | import { detectRepeats, RepeatedInterval } from "./detectRepeats";
4 |
5 | // Zwift Workout XML generator
6 |
7 | const generateTextEvents = (comments: Comment[]): xml.XmlObject[] => {
8 | return comments.map(({ offset, text }) => ({
9 | textevent: [{ _attr: { timeoffset: offset.seconds, message: text } }],
10 | }));
11 | };
12 |
13 | const generateRangeInterval = (
14 | tagName: "Warmup" | "Cooldown" | "Ramp",
15 | { duration, intensity, cadence, comments }: Interval,
16 | ): xml.XmlObject => {
17 | return {
18 | [tagName]: [
19 | {
20 | _attr: {
21 | Duration: duration.seconds,
22 | PowerLow: intensity.start,
23 | PowerHigh: intensity.end,
24 | ...(cadence ? { Cadence: cadence } : {}),
25 | },
26 | },
27 | ...generateTextEvents(comments),
28 | ],
29 | };
30 | };
31 |
32 | const generateSteadyStateInterval = ({ duration, intensity, cadence, comments }: Interval): xml.XmlObject => {
33 | return {
34 | SteadyState: [
35 | {
36 | _attr: {
37 | Duration: duration.seconds,
38 | Power: intensity.value,
39 | ...(cadence ? { Cadence: cadence } : {}),
40 | },
41 | },
42 | ...generateTextEvents(comments),
43 | ],
44 | };
45 | };
46 |
47 | const generateFreeRideInterval = ({ duration, comments }: Interval): xml.XmlObject => {
48 | return {
49 | FreeRide: [
50 | {
51 | _attr: {
52 | Duration: duration.seconds,
53 | },
54 | },
55 | ...generateTextEvents(comments),
56 | ],
57 | };
58 | };
59 |
60 | const generateRepeatInterval = (repInterval: RepeatedInterval): xml.XmlObject => {
61 | const [on, off] = repInterval.intervals;
62 | return {
63 | IntervalsT: [
64 | {
65 | _attr: {
66 | Repeat: repInterval.times,
67 |
68 | OnDuration: on.duration.seconds,
69 | OnPower: on.intensity.start,
70 | ...(on.cadence ? { Cadence: on.cadence } : {}),
71 |
72 | OffDuration: off.duration.seconds,
73 | OffPower: off.intensity.end,
74 | ...(off.cadence ? { CadenceResting: off.cadence } : {}),
75 | },
76 | },
77 | ...generateTextEvents(repInterval.comments),
78 | ],
79 | };
80 | };
81 |
82 | const generateInterval = (
83 | interval: Interval | RepeatedInterval,
84 | index: number,
85 | allIntervals: (Interval | RepeatedInterval)[],
86 | ): xml.XmlObject => {
87 | if (interval.type === "repeat") {
88 | return generateRepeatInterval(interval);
89 | }
90 |
91 | const { intensity } = interval;
92 | if (index === 0 && intensity.start < intensity.end) {
93 | return generateRangeInterval("Warmup", interval);
94 | } else if (index === allIntervals.length - 1 && intensity.start > intensity.end) {
95 | return generateRangeInterval("Cooldown", interval);
96 | } else if (intensity.start !== intensity.end) {
97 | return generateRangeInterval("Ramp", interval);
98 | } else if (intensity.zone === "free") {
99 | return generateFreeRideInterval(interval);
100 | } else {
101 | return generateSteadyStateInterval(interval);
102 | }
103 | };
104 |
105 | const generateTag = (name: string): xml.XmlObject => {
106 | return {
107 | tag: [{ _attr: { name } }],
108 | };
109 | };
110 |
111 | export const generateZwo = ({ name, author, description, tags, intervals }: Workout): string => {
112 | return xml(
113 | {
114 | workout_file: [
115 | { name: name },
116 | { author: author },
117 | { description: description },
118 | { tags: tags.map(generateTag) },
119 | { sportType: "bike" },
120 | { workout: detectRepeats(intervals).map(generateInterval) },
121 | ],
122 | },
123 | { indent: " " },
124 | );
125 | };
126 |
--------------------------------------------------------------------------------
/src/stats/zoneDistribution.test.ts:
--------------------------------------------------------------------------------
1 | import { Interval } from "../ast";
2 | import { Duration } from "../Duration";
3 | import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity";
4 | import { zoneDistribution } from "./zoneDistribution";
5 |
6 | const testZoneDistribution = (intervals: Interval[]) =>
7 | zoneDistribution(intervals).map(({ name, duration }) => [name, duration.seconds]);
8 |
9 | describe("zoneDistribution()", () => {
10 | it("when no intervals, returns 0 for all zones", () => {
11 | expect(testZoneDistribution([])).toEqual([
12 | ["Z1: Recovery", 0],
13 | ["Z2: Endurance", 0],
14 | ["Z3: Tempo", 0],
15 | ["Z4: Threshold", 0],
16 | ["Z5: VO2 Max", 0],
17 | ["Z6: Anaerobic", 0],
18 | ["Freeride", 0],
19 | ]);
20 | });
21 |
22 | it("sums up durations falling within single intensity range", () => {
23 | const intervals: Interval[] = [
24 | {
25 | type: "Interval",
26 | duration: new Duration(100),
27 | intensity: new ConstantIntensity(0.9), // lower bound of Z4
28 | comments: [],
29 | },
30 | {
31 | type: "Interval",
32 | duration: new Duration(100),
33 | intensity: new ConstantIntensity(1.0), // middle of of Z4
34 | comments: [],
35 | },
36 | {
37 | type: "Interval",
38 | duration: new Duration(100),
39 | intensity: new ConstantIntensity(1.04), // upper bound of of Z4
40 | comments: [],
41 | },
42 | ];
43 | expect(testZoneDistribution(intervals)).toEqual([
44 | ["Z1: Recovery", 0],
45 | ["Z2: Endurance", 0],
46 | ["Z3: Tempo", 0],
47 | ["Z4: Threshold", 300],
48 | ["Z5: VO2 Max", 0],
49 | ["Z6: Anaerobic", 0],
50 | ["Freeride", 0],
51 | ]);
52 | });
53 |
54 | it("distributes interval lengths to respective zones", () => {
55 | const intervals: Interval[] = [
56 | {
57 | type: "Interval",
58 | duration: new Duration(100),
59 | intensity: new ConstantIntensity(1.0), // Z4
60 | comments: [],
61 | },
62 | {
63 | type: "Interval",
64 | duration: new Duration(50),
65 | intensity: new ConstantIntensity(0.1), // Z1
66 | comments: [],
67 | },
68 | {
69 | type: "Interval",
70 | duration: new Duration(10),
71 | intensity: new ConstantIntensity(2.0), // Z6
72 | comments: [],
73 | },
74 | ];
75 | expect(testZoneDistribution(intervals)).toEqual([
76 | ["Z1: Recovery", 50],
77 | ["Z2: Endurance", 0],
78 | ["Z3: Tempo", 0],
79 | ["Z4: Threshold", 100],
80 | ["Z5: VO2 Max", 0],
81 | ["Z6: Anaerobic", 10],
82 | ["Freeride", 0],
83 | ]);
84 | });
85 |
86 | it("splits range-intensity interval duration between multiple zones", () => {
87 | const intervals: Interval[] = [
88 | {
89 | type: "Interval",
90 | duration: new Duration(100),
91 | intensity: new RangeIntensity(0.6, 0.9), // Z2..Z3
92 | comments: [],
93 | },
94 | ];
95 | expect(testZoneDistribution(intervals)).toEqual([
96 | ["Z1: Recovery", 0],
97 | ["Z2: Endurance", 50],
98 | ["Z3: Tempo", 50],
99 | ["Z4: Threshold", 0],
100 | ["Z5: VO2 Max", 0],
101 | ["Z6: Anaerobic", 0],
102 | ["Freeride", 0],
103 | ]);
104 | });
105 |
106 | it("places free-intensity duration to special free-zone", () => {
107 | const intervals: Interval[] = [
108 | {
109 | type: "Interval",
110 | duration: new Duration(60),
111 | intensity: new FreeIntensity(),
112 | comments: [],
113 | },
114 | ];
115 | expect(testZoneDistribution(intervals)).toEqual([
116 | ["Z1: Recovery", 0],
117 | ["Z2: Endurance", 0],
118 | ["Z3: Tempo", 0],
119 | ["Z4: Threshold", 0],
120 | ["Z5: VO2 Max", 0],
121 | ["Z6: Anaerobic", 0],
122 | ["Freeride", 60],
123 | ]);
124 | });
125 | });
126 |
--------------------------------------------------------------------------------
/src/stats/xp.test.ts:
--------------------------------------------------------------------------------
1 | import { xp } from "./xp";
2 | import { Interval } from "../ast";
3 | import { Duration } from "../Duration";
4 | import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity";
5 | import { RepeatedInterval } from "../detectRepeats";
6 |
7 | describe("xp()", () => {
8 | describe("ConstantIntensity interval", () => {
9 | const createTestInterval = (seconds: number): Interval => ({
10 | type: "Interval",
11 | duration: new Duration(seconds),
12 | intensity: new ConstantIntensity(100),
13 | comments: [],
14 | });
15 |
16 | [
17 | [1, 0],
18 | [2, 0],
19 | [5, 0],
20 | [6, 1],
21 | [7, 1],
22 | [10, 1],
23 | [15, 2],
24 | [30, 5],
25 | [45, 8],
26 | [50, 8],
27 | [55, 9],
28 | [56, 10],
29 | [57, 10],
30 | [58, 10],
31 | [59, 10],
32 | [60, 10],
33 | [61, 10],
34 | [62, 11],
35 | [63, 11],
36 | [64, 11],
37 | [65, 11],
38 | [1, 0],
39 | [1, 0],
40 | ].forEach(([seconds, expectedXp]) => {
41 | it(`${seconds}s produces ${expectedXp} XP`, () => {
42 | expect(xp([createTestInterval(seconds)])).toEqual(expectedXp);
43 | });
44 | });
45 | });
46 |
47 | describe("RangeIntensity interval", () => {
48 | const createTestInterval = (seconds: number): Interval => ({
49 | type: "Warmup",
50 | duration: new Duration(seconds),
51 | intensity: new RangeIntensity(50, 75),
52 | comments: [],
53 | });
54 |
55 | [
56 | // [50, 4], // Doesn't work :(
57 | [51, 5],
58 | [52, 5],
59 | [53, 5],
60 | [54, 5],
61 | [55, 5],
62 | [56, 5],
63 | [57, 5],
64 | [58, 5],
65 | [59, 5],
66 | [60, 6],
67 | [61, 6],
68 | [65, 6],
69 | [66, 6],
70 | [67, 6],
71 | [68, 6],
72 | [5 * 60, 30],
73 | ].forEach(([seconds, expectedXp]) => {
74 | it(`${seconds}s produces ${expectedXp} XP`, () => {
75 | expect(xp([createTestInterval(seconds)])).toEqual(expectedXp);
76 | });
77 | });
78 | });
79 |
80 | describe("FreeRide interval", () => {
81 | const createTestInterval = (seconds: number): Interval => ({
82 | type: "FreeRide",
83 | duration: new Duration(seconds),
84 | intensity: new FreeIntensity(),
85 | comments: [],
86 | });
87 |
88 | [
89 | [51, 5],
90 | [52, 5],
91 | [53, 5],
92 | [54, 5],
93 | [55, 5],
94 | [56, 5],
95 | [57, 5],
96 | [58, 5],
97 | [59, 5],
98 | // [60, 6], // Doesn't work :(
99 | [61, 6],
100 | [62, 6],
101 | [63, 6],
102 | [64, 6],
103 | [65, 6],
104 | [66, 6],
105 | [67, 6],
106 | [68, 6],
107 | [69, 6],
108 | [2 * 60, 11],
109 | [3 * 60, 17],
110 | ].forEach(([seconds, expectedXp]) => {
111 | it(`${seconds}s produces ${expectedXp} XP`, () => {
112 | expect(xp([createTestInterval(seconds)])).toEqual(expectedXp);
113 | });
114 | });
115 | });
116 |
117 | describe("Repeated interval", () => {
118 | const createTestInterval = (times: number, [onSeconds, offSeconds]: number[]): RepeatedInterval => ({
119 | type: "repeat",
120 | times,
121 | intervals: [
122 | {
123 | type: "Interval",
124 | duration: new Duration(onSeconds),
125 | intensity: new ConstantIntensity(80),
126 | comments: [],
127 | },
128 | {
129 | type: "Interval",
130 | duration: new Duration(offSeconds),
131 | intensity: new ConstantIntensity(70),
132 | comments: [],
133 | },
134 | ],
135 | comments: [],
136 | });
137 |
138 | [
139 | { times: 2, intervals: [1, 1], expectedXp: 0 }, // 0:04
140 | { times: 3, intervals: [1, 1], expectedXp: 1 }, // 0:06
141 | { times: 2, intervals: [14, 14], expectedXp: 11 }, // 0:56
142 | { times: 2, intervals: [14, 15], expectedXp: 11 }, // 0:58
143 | { times: 2, intervals: [15, 15], expectedXp: 11 }, // 1:00
144 | { times: 2, intervals: [15, 16], expectedXp: 12 }, // 1:02
145 | { times: 2, intervals: [16, 16], expectedXp: 12 }, // 1:04
146 | { times: 3, intervals: [14, 15], expectedXp: 17 }, // 1:27
147 | { times: 3, intervals: [15, 16], expectedXp: 18 }, // 1:33
148 | { times: 2, intervals: [30, 30], expectedXp: 23 }, // 2:00
149 | { times: 2, intervals: [60, 60], expectedXp: 47 }, // 4:00
150 | ].forEach(({ times, intervals, expectedXp }) => {
151 | it(`${times} x (${intervals[0]}s on & ${intervals[1]}s off) produces ${expectedXp} XP`, () => {
152 | expect(xp([createTestInterval(times, intervals)])).toEqual(expectedXp);
153 | });
154 | });
155 | });
156 | });
157 |
--------------------------------------------------------------------------------
/src/parser/parser.ts:
--------------------------------------------------------------------------------
1 | import { last } from "ramda";
2 | import { Interval, Workout, Comment } from "../ast";
3 | import { Duration } from "../Duration";
4 | import { ConstantIntensity, FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
5 | import { ParseError } from "./ParseError";
6 | import { IntervalType, OffsetToken, SourceLocation, Token } from "./tokenizer";
7 |
8 | type Header = Partial>;
9 |
10 | const tokenToString = (token: Token | undefined): string => {
11 | return token ? `[${token.type} ${token.value}]` : "EOF";
12 | };
13 |
14 | const extractText = (tokens: Token[]): [string, Token[]] => {
15 | let text;
16 | while (tokens[0] && tokens[0].type === "text") {
17 | if (text === undefined) {
18 | text = tokens[0].value;
19 | } else {
20 | text += "\n" + tokens[0].value;
21 | }
22 | tokens.shift();
23 | }
24 | return [text ? text.trim() : "", tokens];
25 | };
26 |
27 | const parseHeader = (tokens: Token[]): [Header, Token[]] => {
28 | const header: Header = {};
29 |
30 | while (tokens[0]) {
31 | const token = tokens[0];
32 | if (token.type === "header" && token.value === "Name") {
33 | tokens.shift();
34 | const [name, rest] = extractText(tokens);
35 | header.name = name;
36 | tokens = rest;
37 | } else if (token.type === "header" && token.value === "Author") {
38 | tokens.shift();
39 | const [author, rest] = extractText(tokens);
40 | header.author = author;
41 | tokens = rest;
42 | } else if (token.type === "header" && token.value === "Description") {
43 | tokens.shift();
44 | const [description, rest] = extractText(tokens);
45 | header.description = description;
46 | tokens = rest;
47 | } else if (token.type === "header" && token.value === "Tags") {
48 | tokens.shift();
49 | const [tags, rest] = extractText(tokens);
50 | header.tags = tags.split(/\s*,\s*/);
51 | tokens = rest;
52 | } else {
53 | // End of header
54 | break;
55 | }
56 | }
57 |
58 | return [header, tokens];
59 | };
60 |
61 | type PartialComment = {
62 | offsetToken: OffsetToken;
63 | text: string;
64 | loc: SourceLocation;
65 | };
66 |
67 | const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Comment[], Token[]] => {
68 | const comments: PartialComment[] = [];
69 | while (tokens[0]) {
70 | const [start, offset, text, ...rest] = tokens;
71 | if (start.type === "comment-start") {
72 | if (!offset || offset.type !== "offset") {
73 | throw new ParseError(
74 | `Expected [comment offset] instead got ${tokenToString(offset)}`,
75 | offset?.loc || start.loc,
76 | );
77 | }
78 | if (!text || text.type !== "text") {
79 | throw new ParseError(`Expected [comment text] instead got ${tokenToString(text)}`, text?.loc || offset.loc);
80 | }
81 | comments.push({
82 | offsetToken: offset,
83 | text: text.value,
84 | loc: offset.loc,
85 | });
86 | tokens = rest;
87 | } else {
88 | break;
89 | }
90 | }
91 |
92 | return [computeAbsoluteOffsets(comments, intervalDuration), tokens];
93 | };
94 |
95 | const computeAbsoluteOffsets = (partialComments: PartialComment[], intervalDuration: Duration): Comment[] => {
96 | const comments: Comment[] = [];
97 | for (let i = 0; i < partialComments.length; i++) {
98 | const pComment = partialComments[i];
99 | const offsetToken = pComment.offsetToken;
100 |
101 | // Assume absolute offset by default
102 | let offset: Duration = new Duration(offsetToken.value);
103 |
104 | if (offsetToken.kind === "relative-plus") {
105 | // Position relative to previous already-computed comment offset
106 | const previousComment = last(comments);
107 | if (previousComment) {
108 | offset = new Duration(previousComment.offset.seconds + offset.seconds);
109 | }
110 | } else if (offsetToken.kind === "relative-minus") {
111 | // Position relative to next comment or interval end
112 | offset = new Duration(nextCommentOffset(partialComments, i, intervalDuration).seconds - offset.seconds);
113 | }
114 |
115 | comments.push({
116 | offset,
117 | loc: pComment.loc,
118 | text: pComment.text,
119 | });
120 | }
121 | return comments;
122 | };
123 |
124 | const nextCommentOffset = (partialComments: PartialComment[], i: number, intervalDuration: Duration): Duration => {
125 | const nextComment = partialComments[i + 1];
126 | if (!nextComment) {
127 | return intervalDuration;
128 | }
129 | switch (nextComment.offsetToken.kind) {
130 | case "relative-minus":
131 | return new Duration(
132 | nextCommentOffset(partialComments, i + 1, intervalDuration).seconds - nextComment.offsetToken.value,
133 | );
134 | case "relative-plus":
135 | throw new ParseError("Negative offset followed by positive offset", nextComment.offsetToken.loc);
136 | case "absolute":
137 | default:
138 | return new Duration(nextComment.offsetToken.value);
139 | }
140 | };
141 |
142 | const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLocation): [Interval, Token[]] => {
143 | let duration;
144 | let cadence;
145 | let intensity;
146 |
147 | while (tokens[0]) {
148 | const token = tokens[0];
149 | if (token.type === "duration") {
150 | duration = new Duration(token.value);
151 | tokens.shift();
152 | } else if (token.type === "cadence") {
153 | cadence = token.value;
154 | tokens.shift();
155 | } else if (token.type === "intensity") {
156 | intensity = new ConstantIntensity(token.value);
157 | tokens.shift();
158 | } else if (token.type === "intensity-range") {
159 | intensity = new RangeIntensity(token.value[0], token.value[1]);
160 | tokens.shift();
161 | } else if (token.type === "intensity-range-end") {
162 | intensity = new RangeIntensityEnd(token.value);
163 | tokens.shift();
164 | } else {
165 | break;
166 | }
167 | }
168 |
169 | if (!duration) {
170 | throw new ParseError("Duration not specified", loc);
171 | }
172 | if (!intensity) {
173 | intensity = new FreeIntensity();
174 | }
175 |
176 | const [comments, rest] = parseIntervalComments(tokens, duration);
177 |
178 | return [{ type, duration, intensity, cadence, comments }, rest];
179 | };
180 |
181 | const parseIntervals = (tokens: Token[]): Interval[] => {
182 | const intervals: Interval[] = [];
183 |
184 | while (tokens[0]) {
185 | const token = tokens.shift() as Token;
186 | if (token.type === "interval") {
187 | const [interval, rest] = parseIntervalParams(token.value, tokens, token.loc);
188 | intervals.push(interval);
189 | tokens = rest;
190 | } else {
191 | throw new ParseError(`Unexpected token ${tokenToString(token)}`, token.loc);
192 | }
193 | }
194 |
195 | return intervals;
196 | };
197 |
198 | export const parseTokens = (tokens: Token[]): Workout => {
199 | const [header, intervalTokens] = parseHeader(tokens);
200 |
201 | return {
202 | name: header.name || "Untitled",
203 | author: header.author || "",
204 | description: header.description || "",
205 | tags: header.tags || [],
206 | intervals: parseIntervals(intervalTokens),
207 | };
208 | };
209 |
--------------------------------------------------------------------------------
/src/parser/tokenizer.ts:
--------------------------------------------------------------------------------
1 | import { ParseError } from "./ParseError";
2 |
3 | export type HeaderType = "Name" | "Author" | "Description" | "Tags";
4 | export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide" | "Ramp";
5 |
6 | const isHeaderType = (value: string): value is HeaderType => {
7 | return ["Name", "Author", "Description", "Tags"].includes(value);
8 | };
9 | const isIntervalType = (value: string): value is IntervalType => {
10 | return ["Warmup", "Rest", "Interval", "Cooldown", "FreeRide", "Ramp"].includes(value);
11 | };
12 |
13 | // 0-based row and column indexes. First line is 0th.
14 | export type SourceLocation = {
15 | row: number;
16 | col: number;
17 | };
18 |
19 | export type HeaderToken = {
20 | type: "header";
21 | value: HeaderType;
22 | loc: SourceLocation;
23 | };
24 | export type IntervalToken = {
25 | type: "interval";
26 | value: IntervalType;
27 | loc: SourceLocation;
28 | };
29 | export type TextToken = {
30 | type: "text";
31 | value: string;
32 | loc: SourceLocation;
33 | };
34 | export type NumberToken = {
35 | type: "intensity" | "cadence" | "duration";
36 | value: number;
37 | loc: SourceLocation;
38 | };
39 | export type OffsetToken = {
40 | type: "offset";
41 | kind: "absolute" | "relative-plus" | "relative-minus";
42 | value: number;
43 | loc: SourceLocation;
44 | };
45 | export type RangeIntensityToken = {
46 | type: "intensity-range";
47 | value: [number, number];
48 | loc: SourceLocation;
49 | };
50 | export type RangeIntensityEndToken = {
51 | type: "intensity-range-end";
52 | value: number;
53 | loc: SourceLocation;
54 | };
55 | export type CommentStartToken = {
56 | type: "comment-start";
57 | value?: undefined;
58 | loc: SourceLocation;
59 | };
60 | export type Token =
61 | | HeaderToken
62 | | IntervalToken
63 | | TextToken
64 | | NumberToken
65 | | OffsetToken
66 | | RangeIntensityToken
67 | | RangeIntensityEndToken
68 | | CommentStartToken;
69 |
70 | const toInteger = (str: string): number => {
71 | return parseInt(str.replace(/[^0-9]/, ""), 10);
72 | };
73 |
74 | const toSeconds = (str: string): number => {
75 | const [seconds, minutes, hours] = str.split(":").map(toInteger).reverse();
76 | return seconds + minutes * 60 + (hours || 0) * 60 * 60;
77 | };
78 |
79 | const toFraction = (percentage: number): number => percentage / 100;
80 |
81 | const DURATION_REGEX = /^([0-9]{1,2}:)?[0-9]{1,2}:[0-9]{1,2}$/;
82 |
83 | const tokenizeValueParam = (text: string, loc: SourceLocation): Token => {
84 | if (DURATION_REGEX.test(text)) {
85 | return { type: "duration", value: toSeconds(text), loc };
86 | }
87 | if (/^[0-9]+rpm$/.test(text)) {
88 | return { type: "cadence", value: toInteger(text), loc };
89 | }
90 | if (/^[0-9]+%\.\.[0-9]+%$/.test(text)) {
91 | const [from, to] = text.split("..").map(toInteger).map(toFraction);
92 | return { type: "intensity-range", value: [from, to], loc };
93 | }
94 | if (/^\.\.[0-9]+%$/.test(text)) {
95 | const [, /* _ */ to] = text.split("..").map(toInteger).map(toFraction);
96 | return { type: "intensity-range-end", value: to, loc };
97 | }
98 | if (/^[0-9]+%$/.test(text)) {
99 | return { type: "intensity", value: toFraction(toInteger(text)), loc };
100 | }
101 | throw new ParseError(`Unrecognized interval parameter "${text}"`, loc);
102 | };
103 |
104 | const tokenizeParams = (text: string, loc: SourceLocation): Token[] => {
105 | return text.split(/\s+/).map((rawParam) => {
106 | return tokenizeValueParam(rawParam, {
107 | row: loc.row,
108 | // Not fully accurate, but should do for start
109 | col: loc.col + text.indexOf(rawParam),
110 | });
111 | });
112 | };
113 |
114 | const tokenizeComment = (line: string, row: number): Token[] | undefined => {
115 | const [, commentHead, sign, offset, commentText] = line.match(/^(\s*@\s*)([-+]?)([0-9:]+)(.*?)$/) || [];
116 | if (!commentHead) {
117 | return undefined;
118 | }
119 | if (!DURATION_REGEX.test(offset)) {
120 | throw new ParseError("Invalid comment offset", { row, col: commentHead.length });
121 | }
122 | return [
123 | { type: "comment-start", loc: { row, col: line.indexOf("@") } },
124 | {
125 | type: "offset",
126 | kind: signToKind(sign),
127 | value: toSeconds(offset),
128 | loc: { row, col: commentHead.length },
129 | },
130 | { type: "text", value: commentText.trim(), loc: { row, col: commentHead.length + offset.length } },
131 | ];
132 | };
133 |
134 | const signToKind = (sign: string) => {
135 | switch (sign) {
136 | case "-":
137 | return "relative-minus";
138 | case "+":
139 | return "relative-plus";
140 | default:
141 | return "absolute";
142 | }
143 | };
144 |
145 | const tokenizeHeader = (label: HeaderType, separator: string, paramString: string, row: number): Token[] => {
146 | const token: HeaderToken = {
147 | type: "header",
148 | value: label,
149 | loc: { row, col: 0 },
150 | };
151 | const param: TextToken = {
152 | type: "text",
153 | value: paramString,
154 | loc: {
155 | row,
156 | col: label.length + separator.length,
157 | },
158 | };
159 | return [token, param];
160 | };
161 |
162 | const tokenizeInterval = (label: IntervalType, separator: string, paramString: string, row: number): Token[] => {
163 | const token: IntervalToken = {
164 | type: "interval",
165 | value: label,
166 | loc: { row, col: 0 },
167 | };
168 | const params = tokenizeParams(paramString, {
169 | row,
170 | col: label.length + separator.length,
171 | });
172 | return [token, ...params];
173 | };
174 |
175 | const tokenizeLabeledLine = (line: string, row: number): Token[] | undefined => {
176 | const [, label, separator, paramString] = line.match(/^(\w+)(:\s*)(.*?)\s*$/) || [];
177 | if (!label) {
178 | return undefined;
179 | }
180 |
181 | if (isHeaderType(label)) {
182 | return tokenizeHeader(label, separator, paramString, row);
183 | }
184 |
185 | if (isIntervalType(label)) {
186 | return tokenizeInterval(label, separator, paramString, row);
187 | }
188 |
189 | throw new ParseError(`Unknown label "${label}:"`, { row, col: 0 });
190 | };
191 |
192 | const tokenizeText = (line: string, row: number, afterDescription: boolean): TextToken[] => {
193 | if (!afterDescription && line.trim() === "") {
194 | // Ignore empty lines in most cases.
195 | // They're only significant inside description.
196 | return [];
197 | }
198 | return [{ type: "text", value: line.trim(), loc: { row, col: 0 } }];
199 | };
200 |
201 | const tokenizeRule = (line: string, row: number, afterDescription: boolean): Token[] => {
202 | return tokenizeLabeledLine(line, row) || tokenizeComment(line, row) || tokenizeText(line, row, afterDescription);
203 | };
204 |
205 | // True when last token is "Description:" (optionally followed by any number of text tokens)
206 | const isInsideDescription = (tokens: Token[]): boolean => {
207 | for (let i = tokens.length - 1; i >= 0; i--) {
208 | const token = tokens[i];
209 | if (token.type === "text") {
210 | continue;
211 | }
212 | return token.type === "header" && token.value === "Description";
213 | }
214 | return false;
215 | };
216 |
217 | export const tokenize = (file: string): Token[] => {
218 | const tokens: Token[] = [];
219 |
220 | file.split("\n").map((line, row) => {
221 | tokens.push(...tokenizeRule(line, row, isInsideDescription(tokens)));
222 | });
223 |
224 | return tokens;
225 | };
226 |
--------------------------------------------------------------------------------
/src/detectRepeats.test.ts:
--------------------------------------------------------------------------------
1 | import { Interval } from "./ast";
2 | import { detectRepeats } from "./detectRepeats";
3 | import { Duration } from "./Duration";
4 | import { ConstantIntensity, RangeIntensity } from "./Intensity";
5 |
6 | describe("detectRepeats()", () => {
7 | it("does nothing with empty array", () => {
8 | expect(detectRepeats([])).toEqual([]);
9 | });
10 |
11 | it("does nothing when no interval repeats", () => {
12 | const intervals: Interval[] = [
13 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
14 | { type: "Interval", duration: new Duration(60), intensity: new ConstantIntensity(1), comments: [] },
15 | { type: "Interval", duration: new Duration(30), intensity: new ConstantIntensity(1.2), comments: [] },
16 | { type: "Cooldown", duration: new Duration(60), intensity: new ConstantIntensity(1), comments: [] },
17 | ];
18 | expect(detectRepeats(intervals)).toEqual(intervals);
19 | });
20 |
21 | it("detects whole workout consisting of repetitions", () => {
22 | const intervals: Interval[] = [
23 | { type: "Interval", duration: new Duration(120), intensity: new ConstantIntensity(1), comments: [] },
24 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
25 | { type: "Interval", duration: new Duration(120), intensity: new ConstantIntensity(1), comments: [] },
26 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
27 | { type: "Interval", duration: new Duration(120), intensity: new ConstantIntensity(1), comments: [] },
28 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
29 | { type: "Interval", duration: new Duration(120), intensity: new ConstantIntensity(1), comments: [] },
30 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
31 | ];
32 | expect(detectRepeats(intervals)).toEqual([
33 | {
34 | type: "repeat",
35 | times: 4,
36 | intervals: [
37 | { type: "Interval", duration: new Duration(120), intensity: new ConstantIntensity(1), comments: [] },
38 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
39 | ],
40 | comments: [],
41 | },
42 | ]);
43 | });
44 |
45 | it("detects repetitions in the middle of workout", () => {
46 | const intervals: Interval[] = [
47 | { type: "Warmup", duration: new Duration(60), intensity: new RangeIntensity(0.5, 1), comments: [] },
48 | { type: "Rest", duration: new Duration(120), intensity: new ConstantIntensity(0.2), comments: [] },
49 | { type: "Interval", duration: new Duration(60), intensity: new ConstantIntensity(1), comments: [] },
50 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
51 | { type: "Interval", duration: new Duration(60), intensity: new ConstantIntensity(1), comments: [] },
52 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
53 | { type: "Interval", duration: new Duration(60), intensity: new ConstantIntensity(1), comments: [] },
54 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
55 | { type: "Interval", duration: new Duration(60), intensity: new ConstantIntensity(1), comments: [] },
56 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
57 | { type: "Rest", duration: new Duration(120), intensity: new ConstantIntensity(0.2), comments: [] },
58 | { type: "Cooldown", duration: new Duration(60), intensity: new RangeIntensity(1, 0.5), comments: [] },
59 | ];
60 | expect(detectRepeats(intervals)).toEqual([
61 | { type: "Warmup", duration: new Duration(60), intensity: new RangeIntensity(0.5, 1), comments: [] },
62 | { type: "Rest", duration: new Duration(120), intensity: new ConstantIntensity(0.2), comments: [] },
63 | {
64 | type: "repeat",
65 | times: 4,
66 | intervals: [
67 | { type: "Interval", duration: new Duration(60), intensity: new ConstantIntensity(1), comments: [] },
68 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
69 | ],
70 | comments: [],
71 | },
72 | { type: "Rest", duration: new Duration(120), intensity: new ConstantIntensity(0.2), comments: [] },
73 | { type: "Cooldown", duration: new Duration(60), intensity: new RangeIntensity(1, 0.5), comments: [] },
74 | ]);
75 | });
76 |
77 | it("detects multiple repetitions", () => {
78 | const intervals: Interval[] = [
79 | { type: "Interval", duration: new Duration(60), intensity: new ConstantIntensity(1), comments: [] },
80 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
81 | { type: "Interval", duration: new Duration(60), intensity: new ConstantIntensity(1), comments: [] },
82 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
83 | { type: "Interval", duration: new Duration(100), intensity: new ConstantIntensity(1), comments: [] },
84 | { type: "Rest", duration: new Duration(100), intensity: new ConstantIntensity(0.5), comments: [] },
85 | { type: "Interval", duration: new Duration(100), intensity: new ConstantIntensity(1), comments: [] },
86 | { type: "Rest", duration: new Duration(100), intensity: new ConstantIntensity(0.5), comments: [] },
87 | ];
88 | expect(detectRepeats(intervals)).toEqual([
89 | {
90 | type: "repeat",
91 | times: 2,
92 | intervals: [
93 | { type: "Interval", duration: new Duration(60), intensity: new ConstantIntensity(1), comments: [] },
94 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
95 | ],
96 | comments: [],
97 | },
98 | {
99 | type: "repeat",
100 | times: 2,
101 | intervals: [
102 | { type: "Interval", duration: new Duration(100), intensity: new ConstantIntensity(1), comments: [] },
103 | { type: "Rest", duration: new Duration(100), intensity: new ConstantIntensity(0.5), comments: [] },
104 | ],
105 | comments: [],
106 | },
107 | ]);
108 | });
109 |
110 | it("takes cadence differences into account", () => {
111 | const intervals: Interval[] = [
112 | { type: "Interval", duration: new Duration(120), intensity: new ConstantIntensity(1), comments: [] },
113 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
114 | {
115 | type: "Interval",
116 | duration: new Duration(120),
117 | intensity: new ConstantIntensity(1),
118 | cadence: 100,
119 | comments: [],
120 | },
121 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), cadence: 80, comments: [] },
122 | {
123 | type: "Interval",
124 | duration: new Duration(120),
125 | intensity: new ConstantIntensity(1),
126 | cadence: 100,
127 | comments: [],
128 | },
129 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), cadence: 80, comments: [] },
130 | ];
131 | expect(detectRepeats(intervals)).toEqual([
132 | { type: "Interval", duration: new Duration(120), intensity: new ConstantIntensity(1), comments: [] },
133 | { type: "Rest", duration: new Duration(60), intensity: new ConstantIntensity(0.5), comments: [] },
134 | {
135 | type: "repeat",
136 | times: 2,
137 | intervals: [
138 | {
139 | type: "Interval",
140 | duration: new Duration(120),
141 | intensity: new ConstantIntensity(1),
142 | cadence: 100,
143 | comments: [],
144 | },
145 | {
146 | type: "Rest",
147 | duration: new Duration(60),
148 | intensity: new ConstantIntensity(0.5),
149 | cadence: 80,
150 | comments: [],
151 | },
152 | ],
153 | comments: [],
154 | },
155 | ]);
156 | });
157 |
158 | it("does not consider warmup/cooldown-range intervals to be repeatable", () => {
159 | const intervals: Interval[] = [
160 | { type: "Warmup", duration: new Duration(60), intensity: new RangeIntensity(0.1, 1), comments: [] },
161 | { type: "Cooldown", duration: new Duration(120), intensity: new RangeIntensity(1, 0.5), comments: [] },
162 | { type: "Warmup", duration: new Duration(60), intensity: new RangeIntensity(0.1, 1), comments: [] },
163 | { type: "Cooldown", duration: new Duration(120), intensity: new RangeIntensity(1, 0.5), comments: [] },
164 | { type: "Warmup", duration: new Duration(60), intensity: new RangeIntensity(0.1, 1), comments: [] },
165 | { type: "Cooldown", duration: new Duration(120), intensity: new RangeIntensity(1, 0.5), comments: [] },
166 | { type: "Warmup", duration: new Duration(60), intensity: new RangeIntensity(0.1, 1), comments: [] },
167 | { type: "Cooldown", duration: new Duration(120), intensity: new RangeIntensity(1, 0.5), comments: [] },
168 | ];
169 | expect(detectRepeats(intervals)).toEqual(intervals);
170 | });
171 |
172 | it("gathers comments together", () => {
173 | const intervals: Interval[] = [
174 | {
175 | type: "Interval",
176 | duration: new Duration(100),
177 | intensity: new ConstantIntensity(1),
178 | comments: [
179 | { offset: new Duration(0), text: "Let's start", loc: { row: 1, col: 1 } },
180 | { offset: new Duration(20), text: "Stay strong!", loc: { row: 2, col: 1 } },
181 | { offset: new Duration(90), text: "Finish it!", loc: { row: 3, col: 1 } },
182 | ],
183 | },
184 | {
185 | type: "Rest",
186 | duration: new Duration(100),
187 | intensity: new ConstantIntensity(0.5),
188 | comments: [
189 | { offset: new Duration(0), text: "Huh... have a rest", loc: { row: 4, col: 1 } },
190 | { offset: new Duration(80), text: "Ready for next?", loc: { row: 5, col: 1 } },
191 | ],
192 | },
193 | {
194 | type: "Interval",
195 | duration: new Duration(100),
196 | intensity: new ConstantIntensity(1),
197 | comments: [
198 | { offset: new Duration(0), text: "Bring it on again!", loc: { row: 6, col: 1 } },
199 | { offset: new Duration(50), text: "Half way", loc: { row: 7, col: 1 } },
200 | { offset: new Duration(90), text: "Almost there!", loc: { row: 8, col: 1 } },
201 | ],
202 | },
203 | {
204 | type: "Rest",
205 | duration: new Duration(100),
206 | intensity: new ConstantIntensity(0.5),
207 | comments: [
208 | { offset: new Duration(30), text: "Wow... you did it!", loc: { row: 9, col: 1 } },
209 | { offset: new Duration(40), text: "Nice job.", loc: { row: 10, col: 1 } },
210 | { offset: new Duration(50), text: "Until next time...", loc: { row: 11, col: 1 } },
211 | ],
212 | },
213 | ];
214 | expect(detectRepeats(intervals)).toEqual([
215 | {
216 | type: "repeat",
217 | times: 2,
218 | intervals: [
219 | { type: "Interval", duration: new Duration(100), intensity: new ConstantIntensity(1), comments: [] },
220 | { type: "Rest", duration: new Duration(100), intensity: new ConstantIntensity(0.5), comments: [] },
221 | ],
222 | comments: [
223 | { offset: new Duration(0), text: "Let's start", loc: { row: 1, col: 1 } },
224 | { offset: new Duration(20), text: "Stay strong!", loc: { row: 2, col: 1 } },
225 | { offset: new Duration(90), text: "Finish it!", loc: { row: 3, col: 1 } },
226 |
227 | { offset: new Duration(100), text: "Huh... have a rest", loc: { row: 4, col: 1 } },
228 | { offset: new Duration(180), text: "Ready for next?", loc: { row: 5, col: 1 } },
229 |
230 | { offset: new Duration(200), text: "Bring it on again!", loc: { row: 6, col: 1 } },
231 | { offset: new Duration(250), text: "Half way", loc: { row: 7, col: 1 } },
232 | { offset: new Duration(290), text: "Almost there!", loc: { row: 8, col: 1 } },
233 |
234 | { offset: new Duration(330), text: "Wow... you did it!", loc: { row: 9, col: 1 } },
235 | { offset: new Duration(340), text: "Nice job.", loc: { row: 10, col: 1 } },
236 | { offset: new Duration(350), text: "Until next time...", loc: { row: 11, col: 1 } },
237 | ],
238 | },
239 | ]);
240 | });
241 | });
242 |
--------------------------------------------------------------------------------
/test/__snapshots__/cli.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Generate ZWO examples/comments.txt 1`] = `
4 | "
5 | Workout with comments
6 |
7 |
8 |
9 |
10 | bike
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | "
68 | `;
69 |
70 | exports[`Generate ZWO examples/darth-vader.txt 1`] = `
71 | "
72 | Darth Vader
73 | HumanPowerPerformance.com
74 | Sign up for coaching with HumanPowerPerformance.com and get custom workouts and training plans
75 |
76 |
77 | bike
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | "
87 | `;
88 |
89 | exports[`Generate ZWO examples/ftp-test.txt 1`] = `
90 | "
91 | FTP Test (shorter)
92 | Zwift
93 | The short variation of the standard FTP test starts off with a short warmup,
94 | a quick leg opening ramp, and a 5 minute hard effort to get the legs pumping.
95 | After a brief rest it's time to give it your all and go as hard as you can for 20 solid minutes.
96 | Pace yourself and try to go as hard as you can sustain for the entire 20 minutes -
97 | you will be scored on the final 20 minute segment.
98 |
99 | Upon saving your ride, you will be notified if your FTP improved.
100 |
101 |
102 |
103 |
104 |
105 |
106 | bike
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 | "
158 | `;
159 |
160 | exports[`Generate ZWO examples/halvfems.txt 1`] = `
161 | "
162 | Halvfems
163 |
164 | Named after the number 90 in Danish, this workout is focused sweet spot training, centered around 90% of FTP.
165 | This pairs with Devedeset (90 in Croatian) and Novanta (90 in Italian) to make up the sweet spot trifecta in this plan.
166 | The workouts are alphabetically ordered from easiest to hardest, so enjoy the middle of these three challenging workouts today...
167 |
168 |
169 |
170 |
171 | bike
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 | "
227 | `;
228 |
229 | exports[`Generate ZWO examples/ramps.txt 1`] = `
230 | "
231 | Ramps
232 | R.Saarsoo
233 | Various kinds of ramp intervals.
234 |
235 |
236 | bike
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 | "
252 | `;
253 |
254 | exports[`Generate ZWO examples/threshold-pushing.txt 1`] = `
255 | "
256 | Threshold pushing
257 | R.Saarsoo
258 | Start with a good warm up (10 minutes Zone 1, then 5 minutes Zone 2,
259 | 5 minutes Zone 3 followed by 5 minutes easy spinning).
260 | Then straight in at Threshold Pushing powers for 12 mins.
261 | Have 10 mins easy soft pedalling and repeat - warm down 15 minutes.
262 | This session is designed to increase your FTP from below.
263 | Working predominately aerobic metabolism.
264 | In time your body will become more comfortable at these powers
265 | and your FTP will increase.
266 |
267 |
268 | bike
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 | "
280 | `;
281 |
282 | exports[`Generate stats examples/comments.txt 1`] = `
283 | "
284 | Total duration: 24 minutes
285 |
286 | Average intensity: 60%
287 | Normalized intensity: 74%
288 |
289 | TSS: 22
290 | XP: 173
291 |
292 | Zone Distribution:
293 | 15 min - Z1: Recovery
294 | 5 min - Z2: Endurance
295 | 2 min - Z3: Tempo
296 | 1 min - Z4: Threshold
297 | 3 min - Z5: VO2 Max
298 | 0 min - Z6: Anaerobic
299 | 0 min - Freeride
300 | "
301 | `;
302 |
303 | exports[`Generate stats examples/darth-vader.txt 1`] = `
304 | "
305 | Total duration: 43 minutes
306 |
307 | Average intensity: 79%
308 | Normalized intensity: 84%
309 |
310 | TSS: 51
311 | XP: 502
312 |
313 | Zone Distribution:
314 | 10 min - Z1: Recovery
315 | 0 min - Z2: Endurance
316 | 30 min - Z3: Tempo
317 | 0 min - Z4: Threshold
318 | 0 min - Z5: VO2 Max
319 | 3 min - Z6: Anaerobic
320 | 0 min - Freeride
321 | "
322 | `;
323 |
324 | exports[`Generate stats examples/ftp-test.txt 1`] = `
325 | "
326 | Total duration: 45 minutes
327 |
328 | Average intensity: 72%
329 | Normalized intensity: 81%
330 |
331 | TSS: 49
332 | XP: 336
333 |
334 | Zone Distribution:
335 | 15 min - Z1: Recovery
336 | 4 min - Z2: Endurance
337 | 0 min - Z3: Tempo
338 | 0 min - Z4: Threshold
339 | 3 min - Z5: VO2 Max
340 | 2 min - Z6: Anaerobic
341 | 20 min - Freeride
342 | "
343 | `;
344 |
345 | exports[`Generate stats examples/halvfems.txt 1`] = `
346 | "
347 | Total duration: 62 minutes
348 |
349 | Average intensity: 74%
350 | Normalized intensity: 81%
351 |
352 | TSS: 68
353 | XP: 628
354 |
355 | Zone Distribution:
356 | 22 min - Z1: Recovery
357 | 2 min - Z2: Endurance
358 | 0 min - Z3: Tempo
359 | 37 min - Z4: Threshold
360 | 1 min - Z5: VO2 Max
361 | 0 min - Z6: Anaerobic
362 | 0 min - Freeride
363 | "
364 | `;
365 |
366 | exports[`Generate stats examples/ramps.txt 1`] = `
367 | "
368 | Total duration: 50 minutes
369 |
370 | Average intensity: 80%
371 | Normalized intensity: 82%
372 |
373 | TSS: 56
374 | XP: 300
375 |
376 | Zone Distribution:
377 | 6 min - Z1: Recovery
378 | 4 min - Z2: Endurance
379 | 40 min - Z3: Tempo
380 | 0 min - Z4: Threshold
381 | 0 min - Z5: VO2 Max
382 | 0 min - Z6: Anaerobic
383 | 0 min - Freeride
384 | "
385 | `;
386 |
387 | exports[`Generate stats examples/threshold-pushing.txt 1`] = `
388 | "
389 | Total duration: 79 minutes
390 |
391 | Average intensity: 69%
392 | Normalized intensity: 78%
393 |
394 | TSS: 81
395 | XP: 755
396 |
397 | Zone Distribution:
398 | 42 min - Z1: Recovery
399 | 13 min - Z2: Endurance
400 | 0 min - Z3: Tempo
401 | 24 min - Z4: Threshold
402 | 0 min - Z5: VO2 Max
403 | 0 min - Z6: Anaerobic
404 | 0 min - Freeride
405 | "
406 | `;
407 |
--------------------------------------------------------------------------------
/src/parser/parser.test.ts:
--------------------------------------------------------------------------------
1 | import { parse } from ".";
2 |
3 | describe("Parser", () => {
4 | it("creates Untitled workout from empty file", () => {
5 | expect(parse("")).toMatchInlineSnapshot(`
6 | Object {
7 | "author": "",
8 | "description": "",
9 | "intervals": Array [],
10 | "name": "Untitled",
11 | "tags": Array [],
12 | }
13 | `);
14 |
15 | expect(parse(" \n \n \t")).toMatchInlineSnapshot(`
16 | Object {
17 | "author": "",
18 | "description": "",
19 | "intervals": Array [],
20 | "name": "Untitled",
21 | "tags": Array [],
22 | }
23 | `);
24 | });
25 |
26 | it("parses workout with just Name field", () => {
27 | expect(parse(`Name: My Workout`)).toMatchInlineSnapshot(`
28 | Object {
29 | "author": "",
30 | "description": "",
31 | "intervals": Array [],
32 | "name": "My Workout",
33 | "tags": Array [],
34 | }
35 | `);
36 | });
37 |
38 | it("parses workout header with name, author, description", () => {
39 | expect(
40 | parse(`
41 | Name: My Workout
42 | Author: John Doe
43 | Description:
44 | It's a great workout.
45 |
46 | Do it when you dare,
47 | it'll cause lots of pain.
48 | `),
49 | ).toMatchInlineSnapshot(`
50 | Object {
51 | "author": "John Doe",
52 | "description": "It's a great workout.
53 |
54 | Do it when you dare,
55 | it'll cause lots of pain.",
56 | "intervals": Array [],
57 | "name": "My Workout",
58 | "tags": Array [],
59 | }
60 | `);
61 | });
62 |
63 | it("parses workout header with comma-separated tags", () => {
64 | expect(
65 | parse(`
66 | Name: My Workout
67 | Tags: Recovery, Intervals , FTP
68 | `),
69 | ).toMatchInlineSnapshot(`
70 | Object {
71 | "author": "",
72 | "description": "",
73 | "intervals": Array [],
74 | "name": "My Workout",
75 | "tags": Array [
76 | "Recovery",
77 | "Intervals",
78 | "FTP",
79 | ],
80 | }
81 | `);
82 | });
83 |
84 | it("treats with space-separated tags as single tag", () => {
85 | expect(
86 | parse(`
87 | Name: My Workout
88 | Tags: Recovery Intervals FTP
89 | `),
90 | ).toMatchInlineSnapshot(`
91 | Object {
92 | "author": "",
93 | "description": "",
94 | "intervals": Array [],
95 | "name": "My Workout",
96 | "tags": Array [
97 | "Recovery Intervals FTP",
98 | ],
99 | }
100 | `);
101 | });
102 |
103 | it("throws error for unknown labels", () => {
104 | expect(() =>
105 | parse(`
106 | Name: Strange workout
107 | Level: Advanced
108 | `),
109 | ).toThrowErrorMatchingInlineSnapshot(`"Unknown label \\"Level:\\" at line 3 char 1"`);
110 | });
111 |
112 | it("parses basic intervals", () => {
113 | expect(
114 | parse(`
115 | Name: My Workout
116 |
117 | Rest: 5:00 50%
118 |
119 | Interval: 10:00 80% 90rpm
120 |
121 | Rest: 5:00 45%
122 | `).intervals,
123 | ).toMatchInlineSnapshot(`
124 | Array [
125 | Object {
126 | "cadence": undefined,
127 | "comments": Array [],
128 | "duration": Duration {
129 | "seconds": 300,
130 | },
131 | "intensity": ConstantIntensity {
132 | "_value": 0.5,
133 | },
134 | "type": "Rest",
135 | },
136 | Object {
137 | "cadence": 90,
138 | "comments": Array [],
139 | "duration": Duration {
140 | "seconds": 600,
141 | },
142 | "intensity": ConstantIntensity {
143 | "_value": 0.8,
144 | },
145 | "type": "Interval",
146 | },
147 | Object {
148 | "cadence": undefined,
149 | "comments": Array [],
150 | "duration": Duration {
151 | "seconds": 300,
152 | },
153 | "intensity": ConstantIntensity {
154 | "_value": 0.45,
155 | },
156 | "type": "Rest",
157 | },
158 | ]
159 | `);
160 | });
161 |
162 | it("parses intervals after multi-line description", () => {
163 | expect(
164 | parse(`
165 | Name: My Workout
166 | Author: John Doe
167 | Description:
168 | It's a great workout.
169 |
170 | Do it when you dare,
171 | it'll cause lots of pain.
172 |
173 | Interval: 5:00 50%
174 |
175 | Interval: 10:00 100%
176 |
177 | Interval: 5:00 50%
178 | `),
179 | ).toMatchInlineSnapshot(`
180 | Object {
181 | "author": "John Doe",
182 | "description": "It's a great workout.
183 |
184 | Do it when you dare,
185 | it'll cause lots of pain.",
186 | "intervals": Array [
187 | Object {
188 | "cadence": undefined,
189 | "comments": Array [],
190 | "duration": Duration {
191 | "seconds": 300,
192 | },
193 | "intensity": ConstantIntensity {
194 | "_value": 0.5,
195 | },
196 | "type": "Interval",
197 | },
198 | Object {
199 | "cadence": undefined,
200 | "comments": Array [],
201 | "duration": Duration {
202 | "seconds": 600,
203 | },
204 | "intensity": ConstantIntensity {
205 | "_value": 1,
206 | },
207 | "type": "Interval",
208 | },
209 | Object {
210 | "cadence": undefined,
211 | "comments": Array [],
212 | "duration": Duration {
213 | "seconds": 300,
214 | },
215 | "intensity": ConstantIntensity {
216 | "_value": 0.5,
217 | },
218 | "type": "Interval",
219 | },
220 | ],
221 | "name": "My Workout",
222 | "tags": Array [],
223 | }
224 | `);
225 | });
226 |
227 | it("parses power-range intervals", () => {
228 | expect(
229 | parse(`
230 | Name: My Workout
231 |
232 | Warmup: 5:30 50%..80% 100rpm
233 | Cooldown: 5:30 70%..45%
234 | Ramp: 5:30 90%..100%
235 | `).intervals,
236 | ).toMatchInlineSnapshot(`
237 | Array [
238 | Object {
239 | "cadence": 100,
240 | "comments": Array [],
241 | "duration": Duration {
242 | "seconds": 330,
243 | },
244 | "intensity": RangeIntensity {
245 | "_end": 0.8,
246 | "_start": 0.5,
247 | },
248 | "type": "Warmup",
249 | },
250 | Object {
251 | "cadence": undefined,
252 | "comments": Array [],
253 | "duration": Duration {
254 | "seconds": 330,
255 | },
256 | "intensity": RangeIntensity {
257 | "_end": 0.45,
258 | "_start": 0.7,
259 | },
260 | "type": "Cooldown",
261 | },
262 | Object {
263 | "cadence": undefined,
264 | "comments": Array [],
265 | "duration": Duration {
266 | "seconds": 330,
267 | },
268 | "intensity": RangeIntensity {
269 | "_end": 1,
270 | "_start": 0.9,
271 | },
272 | "type": "Ramp",
273 | },
274 | ]
275 | `);
276 | });
277 |
278 | it("parses end-of-range intervals after power-range interval", () => {
279 | expect(
280 | parse(`
281 | Name: My Workout
282 |
283 | Ramp: 5:00 50%..75%
284 | Ramp: 5:00 ..100%
285 | Ramp: 5:00 ..50%
286 | `).intervals,
287 | ).toMatchInlineSnapshot(`
288 | Array [
289 | Object {
290 | "cadence": undefined,
291 | "comments": Array [],
292 | "duration": Duration {
293 | "seconds": 300,
294 | },
295 | "intensity": RangeIntensity {
296 | "_end": 0.75,
297 | "_start": 0.5,
298 | },
299 | "type": "Ramp",
300 | },
301 | Object {
302 | "cadence": undefined,
303 | "comments": Array [],
304 | "duration": Duration {
305 | "seconds": 300,
306 | },
307 | "intensity": RangeIntensity {
308 | "_end": 1,
309 | "_start": 0.75,
310 | },
311 | "type": "Ramp",
312 | },
313 | Object {
314 | "cadence": undefined,
315 | "comments": Array [],
316 | "duration": Duration {
317 | "seconds": 300,
318 | },
319 | "intensity": RangeIntensity {
320 | "_end": 0.5,
321 | "_start": 1,
322 | },
323 | "type": "Ramp",
324 | },
325 | ]
326 | `);
327 | });
328 |
329 | it("parses end-of-range intervals after normal interval", () => {
330 | expect(
331 | parse(`
332 | Name: My Workout
333 |
334 | Interval: 5:00 75%
335 | Ramp: 5:00 ..100%
336 | Ramp: 5:00 ..50%
337 | `).intervals,
338 | ).toMatchInlineSnapshot(`
339 | Array [
340 | Object {
341 | "cadence": undefined,
342 | "comments": Array [],
343 | "duration": Duration {
344 | "seconds": 300,
345 | },
346 | "intensity": ConstantIntensity {
347 | "_value": 0.75,
348 | },
349 | "type": "Interval",
350 | },
351 | Object {
352 | "cadence": undefined,
353 | "comments": Array [],
354 | "duration": Duration {
355 | "seconds": 300,
356 | },
357 | "intensity": RangeIntensity {
358 | "_end": 1,
359 | "_start": 0.75,
360 | },
361 | "type": "Ramp",
362 | },
363 | Object {
364 | "cadence": undefined,
365 | "comments": Array [],
366 | "duration": Duration {
367 | "seconds": 300,
368 | },
369 | "intensity": RangeIntensity {
370 | "_end": 0.5,
371 | "_start": 1,
372 | },
373 | "type": "Ramp",
374 | },
375 | ]
376 | `);
377 | });
378 |
379 | it("throws error when end-of-range intervals after free-ride interval", () => {
380 | expect(
381 | () =>
382 | parse(`
383 | Name: My Workout
384 |
385 | FreeRide: 5:00
386 | Ramp: 5:00 ..100%
387 | Ramp: 5:00 ..50%
388 | `).intervals,
389 | ).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be after free-intensity interval"`);
390 | });
391 |
392 | it("throws error when end-of-range intervals is the first interval", () => {
393 | expect(
394 | () =>
395 | parse(`
396 | Name: My Workout
397 |
398 | Ramp: 5:00 ..50%
399 | `).intervals,
400 | ).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be the first interval"`);
401 | });
402 |
403 | it("parses free-ride intervals", () => {
404 | expect(
405 | parse(`
406 | Name: My Workout
407 |
408 | FreeRide: 5:00
409 | `).intervals,
410 | ).toMatchInlineSnapshot(`
411 | Array [
412 | Object {
413 | "cadence": undefined,
414 | "comments": Array [],
415 | "duration": Duration {
416 | "seconds": 300,
417 | },
418 | "intensity": FreeIntensity {},
419 | "type": "FreeRide",
420 | },
421 | ]
422 | `);
423 | });
424 |
425 | it("Treats any interval without intensity as a free-ride interval", () => {
426 | expect(
427 | parse(`
428 | Name: My Workout
429 |
430 | Interval: 5:00
431 | `).intervals,
432 | ).toMatchInlineSnapshot(`
433 | Array [
434 | Object {
435 | "cadence": undefined,
436 | "comments": Array [],
437 | "duration": Duration {
438 | "seconds": 300,
439 | },
440 | "intensity": FreeIntensity {},
441 | "type": "Interval",
442 | },
443 | ]
444 | `);
445 | });
446 |
447 | const parseInterval = (interval: string) => parse(`Name: My Workout\n${interval}`).intervals[0];
448 |
449 | it("requires duration parameter to be specified", () => {
450 | expect(() => parseInterval("Interval: 50%")).toThrowErrorMatchingInlineSnapshot(
451 | `"Duration not specified at line 2 char 1"`,
452 | );
453 | expect(() => parseInterval("Interval: 10rpm")).toThrowErrorMatchingInlineSnapshot(
454 | `"Duration not specified at line 2 char 1"`,
455 | );
456 | });
457 |
458 | it("allows any order for interval parameters", () => {
459 | expect(parseInterval("Interval: 50% 00:10")).toMatchInlineSnapshot(`
460 | Object {
461 | "cadence": undefined,
462 | "comments": Array [],
463 | "duration": Duration {
464 | "seconds": 10,
465 | },
466 | "intensity": ConstantIntensity {
467 | "_value": 0.5,
468 | },
469 | "type": "Interval",
470 | }
471 | `);
472 | expect(parseInterval("Interval: 50% 100rpm 00:10")).toMatchInlineSnapshot(`
473 | Object {
474 | "cadence": 100,
475 | "comments": Array [],
476 | "duration": Duration {
477 | "seconds": 10,
478 | },
479 | "intensity": ConstantIntensity {
480 | "_value": 0.5,
481 | },
482 | "type": "Interval",
483 | }
484 | `);
485 | expect(parseInterval("Interval: 100rpm 00:10 50%")).toMatchInlineSnapshot(`
486 | Object {
487 | "cadence": 100,
488 | "comments": Array [],
489 | "duration": Duration {
490 | "seconds": 10,
491 | },
492 | "intensity": ConstantIntensity {
493 | "_value": 0.5,
494 | },
495 | "type": "Interval",
496 | }
497 | `);
498 | });
499 |
500 | it("allows whitespace between interval parameters", () => {
501 | expect(parseInterval("Interval: 50% 00:10 100rpm")).toMatchInlineSnapshot(`
502 | Object {
503 | "cadence": 100,
504 | "comments": Array [],
505 | "duration": Duration {
506 | "seconds": 10,
507 | },
508 | "intensity": ConstantIntensity {
509 | "_value": 0.5,
510 | },
511 | "type": "Interval",
512 | }
513 | `);
514 | expect(parseInterval("Interval: \t 50% \t 00:10 \t\t 100rpm \t")).toMatchInlineSnapshot(`
515 | Object {
516 | "cadence": 100,
517 | "comments": Array [],
518 | "duration": Duration {
519 | "seconds": 10,
520 | },
521 | "intensity": ConstantIntensity {
522 | "_value": 0.5,
523 | },
524 | "type": "Interval",
525 | }
526 | `);
527 | });
528 |
529 | it("parses correct duration formats", () => {
530 | expect(parseInterval("Interval: 0:10 50%").duration.seconds).toEqual(10);
531 | expect(parseInterval("Interval: 00:10 50%").duration.seconds).toEqual(10);
532 | expect(parseInterval("Interval: 0:00:10 50%").duration.seconds).toEqual(10);
533 | expect(parseInterval("Interval: 0:02:05 50%").duration.seconds).toEqual(125);
534 | expect(parseInterval("Interval: 1:00:00 50%").duration.seconds).toEqual(3600);
535 | expect(parseInterval("Interval: 1:00:0 50%").duration.seconds).toEqual(3600);
536 | expect(parseInterval("Interval: 1:0:0 50%").duration.seconds).toEqual(3600);
537 | expect(parseInterval("Interval: 10:00:00 50%").duration.seconds).toEqual(36000);
538 | });
539 |
540 | it("throws error for incorrect duration formats", () => {
541 | expect(() => parseInterval("Interval: 10 50%")).toThrowErrorMatchingInlineSnapshot(
542 | `"Unrecognized interval parameter \\"10\\" at line 2 char 11"`,
543 | );
544 | expect(() => parseInterval("Interval: :10 50%")).toThrowErrorMatchingInlineSnapshot(
545 | `"Unrecognized interval parameter \\":10\\" at line 2 char 11"`,
546 | );
547 | expect(() => parseInterval("Interval: 0:100 50%")).toThrowErrorMatchingInlineSnapshot(
548 | `"Unrecognized interval parameter \\"0:100\\" at line 2 char 11"`,
549 | );
550 | expect(() => parseInterval("Interval: 00:00:00:10 50%")).toThrowErrorMatchingInlineSnapshot(
551 | `"Unrecognized interval parameter \\"00:00:00:10\\" at line 2 char 11"`,
552 | );
553 | });
554 |
555 | it("throws error for unexpected interval parameter", () => {
556 | expect(() => parseInterval("Interval: 10:00 50% foobar")).toThrowErrorMatchingInlineSnapshot(
557 | `"Unrecognized interval parameter \\"foobar\\" at line 2 char 21"`,
558 | );
559 | expect(() => parseInterval("Interval: 10:00 50% 123blah")).toThrowErrorMatchingInlineSnapshot(
560 | `"Unrecognized interval parameter \\"123blah\\" at line 2 char 21"`,
561 | );
562 | expect(() => parseInterval("Interval: 10:00 50% ^*&")).toThrowErrorMatchingInlineSnapshot(
563 | `"Unrecognized interval parameter \\"^*&\\" at line 2 char 21"`,
564 | );
565 | });
566 |
567 | it("throws error for unexpected type of interval", () => {
568 | expect(() => parseInterval("Interval: 30:00 5% \n CustomInterval: 15:00 10%")).toThrowErrorMatchingInlineSnapshot(
569 | `"Unexpected token [text CustomInterval: 15:00 10%] at line 3 char 1"`,
570 | );
571 | });
572 |
573 | it("parses intervals with comments", () => {
574 | expect(
575 | parse(`
576 | Name: My Workout
577 | Interval: 10:00 90%
578 | @ 0:00 Find your rythm.
579 | @ 1:00 Try to settle in for the effort
580 |
581 | @ 5:00 Half way through
582 |
583 | @ 9:00 Almost there
584 | @ 9:30 Final push. YOU GOT IT!
585 |
586 | Rest: 5:00 50%
587 | @ 0:00 Great effort!
588 | @ 0:30 Cool down well after all of this.
589 | `),
590 | ).toMatchInlineSnapshot(`
591 | Object {
592 | "author": "",
593 | "description": "",
594 | "intervals": Array [
595 | Object {
596 | "cadence": undefined,
597 | "comments": Array [
598 | Object {
599 | "loc": Object {
600 | "col": 4,
601 | "row": 3,
602 | },
603 | "offset": Duration {
604 | "seconds": 0,
605 | },
606 | "text": "Find your rythm.",
607 | },
608 | Object {
609 | "loc": Object {
610 | "col": 4,
611 | "row": 4,
612 | },
613 | "offset": Duration {
614 | "seconds": 60,
615 | },
616 | "text": "Try to settle in for the effort",
617 | },
618 | Object {
619 | "loc": Object {
620 | "col": 4,
621 | "row": 6,
622 | },
623 | "offset": Duration {
624 | "seconds": 300,
625 | },
626 | "text": "Half way through",
627 | },
628 | Object {
629 | "loc": Object {
630 | "col": 4,
631 | "row": 8,
632 | },
633 | "offset": Duration {
634 | "seconds": 540,
635 | },
636 | "text": "Almost there",
637 | },
638 | Object {
639 | "loc": Object {
640 | "col": 4,
641 | "row": 9,
642 | },
643 | "offset": Duration {
644 | "seconds": 570,
645 | },
646 | "text": "Final push. YOU GOT IT!",
647 | },
648 | ],
649 | "duration": Duration {
650 | "seconds": 600,
651 | },
652 | "intensity": ConstantIntensity {
653 | "_value": 0.9,
654 | },
655 | "type": "Interval",
656 | },
657 | Object {
658 | "cadence": undefined,
659 | "comments": Array [
660 | Object {
661 | "loc": Object {
662 | "col": 4,
663 | "row": 12,
664 | },
665 | "offset": Duration {
666 | "seconds": 0,
667 | },
668 | "text": "Great effort!",
669 | },
670 | Object {
671 | "loc": Object {
672 | "col": 4,
673 | "row": 13,
674 | },
675 | "offset": Duration {
676 | "seconds": 30,
677 | },
678 | "text": "Cool down well after all of this.",
679 | },
680 | ],
681 | "duration": Duration {
682 | "seconds": 300,
683 | },
684 | "intensity": ConstantIntensity {
685 | "_value": 0.5,
686 | },
687 | "type": "Rest",
688 | },
689 | ],
690 | "name": "My Workout",
691 | "tags": Array [],
692 | }
693 | `);
694 | });
695 |
696 | it("parses last comment with negative offset", () => {
697 | expect(
698 | parse(`
699 | Name: My Workout
700 | Interval: 10:00 90%
701 | @ 0:10 Find your rythm.
702 | @ -0:10 Final push. YOU GOT IT!
703 | `),
704 | ).toMatchInlineSnapshot(`
705 | Object {
706 | "author": "",
707 | "description": "",
708 | "intervals": Array [
709 | Object {
710 | "cadence": undefined,
711 | "comments": Array [
712 | Object {
713 | "loc": Object {
714 | "col": 4,
715 | "row": 3,
716 | },
717 | "offset": Duration {
718 | "seconds": 10,
719 | },
720 | "text": "Find your rythm.",
721 | },
722 | Object {
723 | "loc": Object {
724 | "col": 4,
725 | "row": 4,
726 | },
727 | "offset": Duration {
728 | "seconds": 590,
729 | },
730 | "text": "Final push. YOU GOT IT!",
731 | },
732 | ],
733 | "duration": Duration {
734 | "seconds": 600,
735 | },
736 | "intensity": ConstantIntensity {
737 | "_value": 0.9,
738 | },
739 | "type": "Interval",
740 | },
741 | ],
742 | "name": "My Workout",
743 | "tags": Array [],
744 | }
745 | `);
746 | });
747 |
748 | it("parses comment with negative offset before absolutely offset comment", () => {
749 | expect(
750 | parse(`
751 | Name: My Workout
752 | Interval: 1:00 90%
753 | @ -0:10 Before last
754 | @ 0:50 Last!
755 | `),
756 | ).toMatchInlineSnapshot(`
757 | Object {
758 | "author": "",
759 | "description": "",
760 | "intervals": Array [
761 | Object {
762 | "cadence": undefined,
763 | "comments": Array [
764 | Object {
765 | "loc": Object {
766 | "col": 4,
767 | "row": 3,
768 | },
769 | "offset": Duration {
770 | "seconds": 40,
771 | },
772 | "text": "Before last",
773 | },
774 | Object {
775 | "loc": Object {
776 | "col": 4,
777 | "row": 4,
778 | },
779 | "offset": Duration {
780 | "seconds": 50,
781 | },
782 | "text": "Last!",
783 | },
784 | ],
785 | "duration": Duration {
786 | "seconds": 60,
787 | },
788 | "intensity": ConstantIntensity {
789 | "_value": 0.9,
790 | },
791 | "type": "Interval",
792 | },
793 | ],
794 | "name": "My Workout",
795 | "tags": Array [],
796 | }
797 | `);
798 | });
799 |
800 | it("parses multiple comments with negative offsets in row", () => {
801 | expect(
802 | parse(`
803 | Name: My Workout
804 | Interval: 1:00 90%
805 | @ -0:10 One more before last
806 | @ -0:10 Before last
807 | @ -0:10 Last!
808 | `),
809 | ).toMatchInlineSnapshot(`
810 | Object {
811 | "author": "",
812 | "description": "",
813 | "intervals": Array [
814 | Object {
815 | "cadence": undefined,
816 | "comments": Array [
817 | Object {
818 | "loc": Object {
819 | "col": 4,
820 | "row": 3,
821 | },
822 | "offset": Duration {
823 | "seconds": 30,
824 | },
825 | "text": "One more before last",
826 | },
827 | Object {
828 | "loc": Object {
829 | "col": 4,
830 | "row": 4,
831 | },
832 | "offset": Duration {
833 | "seconds": 40,
834 | },
835 | "text": "Before last",
836 | },
837 | Object {
838 | "loc": Object {
839 | "col": 4,
840 | "row": 5,
841 | },
842 | "offset": Duration {
843 | "seconds": 50,
844 | },
845 | "text": "Last!",
846 | },
847 | ],
848 | "duration": Duration {
849 | "seconds": 60,
850 | },
851 | "intensity": ConstantIntensity {
852 | "_value": 0.9,
853 | },
854 | "type": "Interval",
855 | },
856 | ],
857 | "name": "My Workout",
858 | "tags": Array [],
859 | }
860 | `);
861 | });
862 |
863 | it("parses intervals with positive comment offsets", () => {
864 | expect(
865 | parse(`
866 | Name: My Workout
867 | Interval: 10:00 90%
868 | @ 0:50 First comment
869 | @ +0:10 Comment #2 10 seconds later
870 | @ +0:10 Comment #3 another 10 seconds later
871 | @ 5:00 Half way!
872 | @ +0:10 Comment #5 10 seconds later
873 | @ +0:10 Comment #6 another 10 seconds later
874 | `),
875 | ).toMatchInlineSnapshot(`
876 | Object {
877 | "author": "",
878 | "description": "",
879 | "intervals": Array [
880 | Object {
881 | "cadence": undefined,
882 | "comments": Array [
883 | Object {
884 | "loc": Object {
885 | "col": 4,
886 | "row": 3,
887 | },
888 | "offset": Duration {
889 | "seconds": 50,
890 | },
891 | "text": "First comment",
892 | },
893 | Object {
894 | "loc": Object {
895 | "col": 4,
896 | "row": 4,
897 | },
898 | "offset": Duration {
899 | "seconds": 60,
900 | },
901 | "text": "Comment #2 10 seconds later",
902 | },
903 | Object {
904 | "loc": Object {
905 | "col": 4,
906 | "row": 5,
907 | },
908 | "offset": Duration {
909 | "seconds": 70,
910 | },
911 | "text": "Comment #3 another 10 seconds later",
912 | },
913 | Object {
914 | "loc": Object {
915 | "col": 4,
916 | "row": 6,
917 | },
918 | "offset": Duration {
919 | "seconds": 300,
920 | },
921 | "text": "Half way!",
922 | },
923 | Object {
924 | "loc": Object {
925 | "col": 4,
926 | "row": 7,
927 | },
928 | "offset": Duration {
929 | "seconds": 310,
930 | },
931 | "text": "Comment #5 10 seconds later",
932 | },
933 | Object {
934 | "loc": Object {
935 | "col": 4,
936 | "row": 8,
937 | },
938 | "offset": Duration {
939 | "seconds": 320,
940 | },
941 | "text": "Comment #6 another 10 seconds later",
942 | },
943 | ],
944 | "duration": Duration {
945 | "seconds": 600,
946 | },
947 | "intensity": ConstantIntensity {
948 | "_value": 0.9,
949 | },
950 | "type": "Interval",
951 | },
952 | ],
953 | "name": "My Workout",
954 | "tags": Array [],
955 | }
956 | `);
957 | });
958 |
959 | it("treats positive comment offset as relative to interval start when there's no previous comment", () => {
960 | expect(
961 | parse(`
962 | Name: My Workout
963 | Interval: 10:00 90%
964 | @ +1:00 First comment
965 | `),
966 | ).toMatchInlineSnapshot(`
967 | Object {
968 | "author": "",
969 | "description": "",
970 | "intervals": Array [
971 | Object {
972 | "cadence": undefined,
973 | "comments": Array [
974 | Object {
975 | "loc": Object {
976 | "col": 4,
977 | "row": 3,
978 | },
979 | "offset": Duration {
980 | "seconds": 60,
981 | },
982 | "text": "First comment",
983 | },
984 | ],
985 | "duration": Duration {
986 | "seconds": 600,
987 | },
988 | "intensity": ConstantIntensity {
989 | "_value": 0.9,
990 | },
991 | "type": "Interval",
992 | },
993 | ],
994 | "name": "My Workout",
995 | "tags": Array [],
996 | }
997 | `);
998 | });
999 |
1000 | it("throws error when negative offset is followed by positive offset", () => {
1001 | expect(() =>
1002 | parse(`
1003 | Name: My Workout
1004 | Interval: 2:00 90%
1005 | @ -0:10 Comment 1
1006 | @ +0:30 Comment 2
1007 | @ 1:30 Comment 3
1008 | `),
1009 | ).toThrowErrorMatchingInlineSnapshot(`"Negative offset followed by positive offset at line 5 char 5"`);
1010 | });
1011 |
1012 | it("works fine when positive offset is followed by negative offset", () => {
1013 | expect(
1014 | parse(`
1015 | Name: My Workout
1016 | Interval: 1:00 90%
1017 | @ +0:10 Comment 1
1018 | @ -0:10 Comment 2
1019 | `),
1020 | ).toMatchInlineSnapshot(`
1021 | Object {
1022 | "author": "",
1023 | "description": "",
1024 | "intervals": Array [
1025 | Object {
1026 | "cadence": undefined,
1027 | "comments": Array [
1028 | Object {
1029 | "loc": Object {
1030 | "col": 4,
1031 | "row": 3,
1032 | },
1033 | "offset": Duration {
1034 | "seconds": 10,
1035 | },
1036 | "text": "Comment 1",
1037 | },
1038 | Object {
1039 | "loc": Object {
1040 | "col": 4,
1041 | "row": 4,
1042 | },
1043 | "offset": Duration {
1044 | "seconds": 50,
1045 | },
1046 | "text": "Comment 2",
1047 | },
1048 | ],
1049 | "duration": Duration {
1050 | "seconds": 60,
1051 | },
1052 | "intensity": ConstantIntensity {
1053 | "_value": 0.9,
1054 | },
1055 | "type": "Interval",
1056 | },
1057 | ],
1058 | "name": "My Workout",
1059 | "tags": Array [],
1060 | }
1061 | `);
1062 | });
1063 |
1064 | it("throws error when comment offset is outside of interval length", () => {
1065 | expect(() =>
1066 | parse(`
1067 | Name: My Workout
1068 | Interval: 2:00 90%
1069 | @ 0:00 Find your rythm.
1070 | @ 3:10 Try to settle in for the effort
1071 | `),
1072 | ).toThrowErrorMatchingInlineSnapshot(`"Comment offset is larger than interval length at line 5 char 5"`);
1073 | });
1074 |
1075 | it("throws error when negative comment offset is outside of interval", () => {
1076 | expect(() =>
1077 | parse(`
1078 | Name: My Workout
1079 | Interval: 2:00 90%
1080 | @ 0:00 Find your rythm.
1081 | @ -3:10 Try to settle in for the effort
1082 | `),
1083 | ).toThrowErrorMatchingInlineSnapshot(`"Negative comment offset is larger than interval length at line 5 char 5"`);
1084 | });
1085 |
1086 | it("throws error when comment offset is the same as another comment offset", () => {
1087 | expect(() =>
1088 | parse(`
1089 | Name: My Workout
1090 | Interval: 2:00 90%
1091 | @ 0:00 First comment
1092 | @ 1:00 Comment
1093 | @ 1:00 Overlapping comment
1094 | @ 1:50 Last comment
1095 | `),
1096 | ).toThrowErrorMatchingInlineSnapshot(`"Comment overlaps previous comment at line 6 char 5"`);
1097 | });
1098 |
1099 | it("throws error when comment offset is greater than next comment offset", () => {
1100 | expect(() =>
1101 | parse(`
1102 | Name: My Workout
1103 | Interval: 2:00 90%
1104 | @ 0:00 First comment
1105 | @ 1:20 Comment
1106 | @ 1:00 Misplaced comment
1107 | @ 1:50 Last comment
1108 | `),
1109 | ).toThrowErrorMatchingInlineSnapshot(`"Comment overlaps previous comment at line 6 char 5"`);
1110 | });
1111 |
1112 | it("throws error when comments too close together", () => {
1113 | expect(() =>
1114 | parse(`
1115 | Name: My Workout
1116 | Interval: 2:00 90%
1117 | @ 0:00 First comment
1118 | @ 0:01 Second Comment
1119 | `),
1120 | ).toThrowErrorMatchingInlineSnapshot(`"Less than 10 seconds between comments at line 5 char 5"`);
1121 |
1122 | expect(() =>
1123 | parse(`
1124 | Name: My Workout
1125 | Interval: 2:00 90%
1126 | @ 0:00 First comment
1127 | @ 0:09 Second Comment
1128 | `),
1129 | ).toThrowErrorMatchingInlineSnapshot(`"Less than 10 seconds between comments at line 5 char 5"`);
1130 | });
1131 |
1132 | it("triggers no error when comments at least 10 seconds apart", () => {
1133 | expect(() =>
1134 | parse(`
1135 | Name: My Workout
1136 | Interval: 2:00 90%
1137 | @ 0:00 First comment
1138 | @ 0:10 Second Comment
1139 | `),
1140 | ).not.toThrowError();
1141 | });
1142 |
1143 | it("throws error when comment does not finish before end of interval", () => {
1144 | expect(() =>
1145 | parse(`
1146 | Name: My Workout
1147 | Interval: 1:00 80%
1148 | @ 0:51 First comment
1149 | Interval: 1:00 90%
1150 | `),
1151 | ).toThrowErrorMatchingInlineSnapshot(
1152 | `"Less than 10 seconds between comment start and interval end at line 4 char 5"`,
1153 | );
1154 | });
1155 |
1156 | it("triggers no error when comment finishes right at interval end", () => {
1157 | expect(() =>
1158 | parse(`
1159 | Name: My Workout
1160 | Interval: 1:00 80%
1161 | @ 0:50 First comment
1162 | Interval: 1:00 90%
1163 | `),
1164 | ).not.toThrowError();
1165 | });
1166 | });
1167 |
--------------------------------------------------------------------------------