diff --git a/README.md b/README.md index 4bcf888..7ba9bce 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ ## TODO -- FreeRide blocks - Repeats (and nested repeats) - Unsupported params: message duration & y-position - More restricted syntax for text (with quotes) diff --git a/examples/ftp-test.txt b/examples/ftp-test.txt new file mode 100644 index 0000000..9bdc5e8 --- /dev/null +++ b/examples/ftp-test.txt @@ -0,0 +1,41 @@ +Author: Zwift +Name: FTP Test (shorter) +Description: + 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. + + Upon saving your ride, you will be notified if your FTP improved. + +Warmup: 5:00 30%..70% + @ 00:20 Welcome to the FTP test + @ 00:35 Time to get warmed up and get your cadence up to 90-100rpm + +Interval: 00:20 90% +Interval: 00:20 110% +Interval: 00:20 130% + +Interval: 03:00 60% +Interval: 03:00 110% +Interval: 02:00 120% + +Rest: 06:00 55% + @ 00:10 In 6 minutes the FTP test begins + @ 05:00 Prepare for your max 20 minute effort + +FreeRide: 20:00 + @ 00:10 Bring your power up to what you think you can hold for 20 minutes + @ 05:00 How ya doin? + @ 10:00 Half way done! If it's feeling easy now might be time to add 10 watts. + @ 15:00 5 minutes left! + @ 16:00 4 minutes left. Increase your power if you're feeling strong. + @ 17:00 3 minutes left + @ 18:00 2 minutes left + @ 19:00 1 minute left. Go all in! + @ 19:30 30 sec. Go for it. + @ 19:50 10 seconds. + +Cooldown: 5:00 50%..30% + diff --git a/examples/ftp-test.zwo b/examples/ftp-test.zwo new file mode 100644 index 0000000..a8fd159 --- /dev/null +++ b/examples/ftp-test.zwo @@ -0,0 +1,42 @@ + + Zwift + FTP Test (shorter) + 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. + +Upon saving your ride, you will be notified if your FTP improved. + bike + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Intensity.ts b/src/Intensity.ts index f209763..ea9d3bb 100644 --- a/src/Intensity.ts +++ b/src/Intensity.ts @@ -29,3 +29,17 @@ export class IntensityRange { return this._end; } } + +export class FreeIntensity { + get value() { + return 0; + } + + get start() { + return 0; + } + + get end() { + return 0; + } +} diff --git a/src/ast.ts b/src/ast.ts index 654ff87..d2e4837 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -1,6 +1,6 @@ import { IntervalType } from "./parser/tokenizer"; import { Duration } from "./Duration"; -import { Intensity, IntensityRange } from "./Intensity"; +import { FreeIntensity, Intensity, IntensityRange } from "./Intensity"; export type Workout = { name: string; @@ -12,7 +12,7 @@ export type Workout = { export type Interval = { type: IntervalType; duration: Duration; - intensity: Intensity | IntensityRange; + intensity: Intensity | IntensityRange | FreeIntensity; cadence?: number; comments: Comment[]; }; diff --git a/src/generateZwo.ts b/src/generateZwo.ts index 1d42d51..bb63302 100644 --- a/src/generateZwo.ts +++ b/src/generateZwo.ts @@ -1,6 +1,7 @@ import * as xml from "xml"; import { Interval, Workout, Comment } from "./ast"; import { detectRepeats, RepeatedInterval } from "./detectRepeats"; +import { FreeIntensity } from "./Intensity"; // Zwift Workout XML generator @@ -44,6 +45,19 @@ const generateSteadyStateInterval = ({ duration, intensity, cadence, comments }: }; }; +const generateFreeRideInterval = ({ duration, comments }: Interval): xml.XmlObject => { + return { + FreeRide: [ + { + _attr: { + Duration: duration.seconds, + }, + }, + ...generateTextEvents(comments), + ], + }; +}; + const generateRepeatInterval = (repInterval: RepeatedInterval): xml.XmlObject => { const [on, off] = repInterval.intervals; return { @@ -76,6 +90,8 @@ const generateInterval = (interval: Interval | RepeatedInterval): xml.XmlObject return generateRangeInterval("Warmup", interval); } else if (intensity.start > intensity.end) { return generateRangeInterval("Cooldown", interval); + } else if (intensity instanceof FreeIntensity) { + return generateFreeRideInterval(interval); } else { return generateSteadyStateInterval(interval); } diff --git a/src/parser/parser.test.ts b/src/parser/parser.test.ts index 6ccfe42..1c84a5a 100644 --- a/src/parser/parser.test.ts +++ b/src/parser/parser.test.ts @@ -207,6 +207,28 @@ Cooldown: 5:30 70%..45% `); }); + it("parses free-ride intervals", () => { + expect( + parse(` +Name: My Workout + +FreeRide: 5:00 +`).intervals, + ).toMatchInlineSnapshot(` + Array [ + Object { + "cadence": undefined, + "comments": Array [], + "duration": Duration { + "seconds": 300, + }, + "intensity": FreeIntensity {}, + "type": "FreeRide", + }, + ] + `); + }); + const parseInterval = (interval: string) => parse(`Name: My Workout\n${interval}`).intervals[0]; it("requires duration and power parameters to be specified", () => { diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 29f6c42..65bee43 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1,8 +1,8 @@ import { Interval, Workout, Comment } from "../ast"; import { Duration } from "../Duration"; -import { Intensity, IntensityRange } from "../Intensity"; +import { FreeIntensity, Intensity, IntensityRange } from "../Intensity"; import { ParseError } from "./ParseError"; -import { SourceLocation, Token } from "./tokenizer"; +import { IntervalType, SourceLocation, Token } from "./tokenizer"; type Header = Partial>; @@ -78,41 +78,44 @@ const parseIntervalComments = (tokens: Token[]): [Comment[], Token[]] => { return [comments, tokens]; }; -type IntervalData = Omit; - -const parseIntervalParams = (tokens: Token[], loc: SourceLocation): [IntervalData, Token[]] => { - const data: Partial = {}; +const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLocation): [Interval, Token[]] => { + let duration; + let cadence; + let intensity; while (tokens[0]) { const token = tokens[0]; if (token.type === "duration") { - data.duration = new Duration(token.value); + duration = new Duration(token.value); tokens.shift(); } else if (token.type === "cadence") { - data.cadence = token.value; + cadence = token.value; tokens.shift(); } else if (token.type === "intensity") { - data.intensity = new Intensity(token.value); + intensity = new Intensity(token.value); tokens.shift(); } else if (token.type === "intensity-range") { - data.intensity = new IntensityRange(token.value[0], token.value[1]); + intensity = new IntensityRange(token.value[0], token.value[1]); tokens.shift(); } else { break; } } - if (!("duration" in data)) { + if (!duration) { throw new ParseError("Duration not specified", loc); } - if (!("intensity" in data)) { - throw new ParseError("Power not specified", loc); + if (!intensity) { + if (type === "FreeRide") { + intensity = new FreeIntensity(); + } else { + throw new ParseError("Power not specified", loc); + } } const [comments, rest] = parseIntervalComments(tokens); - data.comments = comments; - return [data as IntervalData, rest]; + return [{ type, duration, intensity, cadence, comments }, rest]; }; const parseIntervals = (tokens: Token[]): Interval[] => { @@ -121,14 +124,8 @@ const parseIntervals = (tokens: Token[]): Interval[] => { while (tokens[0]) { const token = tokens.shift() as Token; if (token.type === "interval") { - const [{ duration, intensity, cadence, comments }, rest] = parseIntervalParams(tokens, token.loc); - intervals.push({ - type: token.value, - duration, - intensity, - cadence, - comments, - }); + const [interval, rest] = parseIntervalParams(token.value, tokens, token.loc); + intervals.push(interval); tokens = rest; } else { throw new ParseError(`Unexpected token ${tokenToString(token)}`, token.loc); diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 2884857..05c62f7 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -1,13 +1,13 @@ import { ParseError } from "./ParseError"; export type HeaderType = "Name" | "Author" | "Description"; -export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown"; +export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide"; const isHeaderType = (value: string): value is HeaderType => { return ["Name", "Author", "Description"].includes(value); }; const isIntervalType = (value: string): value is IntervalType => { - return ["Warmup", "Rest", "Interval", "Cooldown"].includes(value); + return ["Warmup", "Rest", "Interval", "Cooldown", "FreeRide"].includes(value); }; // 0-based row and column indexes. First line is 0th. diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index 54a7d22..fdfcd15 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -78,6 +78,69 @@ exports[`Generate ZWO examples/darth-vader.txt 1`] = ` " `; +exports[`Generate ZWO examples/ftp-test.txt 1`] = ` +" + FTP Test (shorter) + Zwift + 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. + +Upon saving your ride, you will be notified if your FTP improved. + bike + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; + exports[`Generate ZWO examples/halvfems.txt 1`] = ` " Halvfems @@ -205,6 +268,26 @@ Zone Distribution: " `; +exports[`Generate stats examples/ftp-test.txt 1`] = ` +" +Total duration: 45 minutes + +Average intensity: 36% +Normalized intensity: 71% + +TSS #1: 21 +TSS #2: 37 + +Zone Distribution: + 35 min - Z1: Recovery + 4 min - Z2: Endurance + 0 min - Z3: Tempo + 0 min - Z4: Threshold + 3 min - Z5: VO2 Max + 2 min - Z6: Anaerobic +" +`; + exports[`Generate stats examples/halvfems.txt 1`] = ` " Total duration: 62 minutes diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 187d07d..630495c 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -9,6 +9,7 @@ const createZwo = (filename: string) => generateZwo(parse(fs.readFileSync(filena const filenames = [ "examples/comments.txt", "examples/darth-vader.txt", + "examples/ftp-test.txt", "examples/halvfems.txt", "examples/threshold-pushing.txt", ];