├── .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 | --------------------------------------------------------------------------------