diff --git a/src/Intensity.ts b/src/Intensity.ts index 544298c..58cb56a 100644 --- a/src/Intensity.ts +++ b/src/Intensity.ts @@ -47,6 +47,26 @@ export class RangeIntensity implements Intensity { } } +export class RangeIntensityEnd implements Intensity { + constructor(private _end: number) {} + + get value() { + return this._end; + } + + get start(): number { + throw new Error("RangeIntensityEnd has no start"); + } + + get end() { + return this._end; + } + + get zone() { + return intensityValueToZoneType(this.value); + } +} + export class FreeIntensity implements Intensity { get value() { // To match Zwift, which gives 64 TSS for 1h of freeride. diff --git a/src/parser/fillRangeIntensities.ts b/src/parser/fillRangeIntensities.ts new file mode 100644 index 0000000..46c68e8 --- /dev/null +++ b/src/parser/fillRangeIntensities.ts @@ -0,0 +1,45 @@ +import { Interval, Workout } from "../ast"; +import { FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity"; + +const fillIntensities = (prevInterval: Interval, interval: Interval): Interval => { + if (!(interval.intensity instanceof RangeIntensityEnd)) { + return interval; + } + + if (prevInterval.intensity instanceof FreeIntensity) { + throw new Error("range-intensity-end interval can't be after free-intensity interval"); + } + + return { + ...interval, + intensity: new RangeIntensity(prevInterval.intensity.end, interval.intensity.end), + }; +}; + +// Given: [1, 2, 3, 4] +// Returns: [[1,2], [2,3], [3,4]] +const pairs = (arr: T[]): T[][] => { + const result: T[][] = []; + for (let i = 1; i < arr.length; i++) { + result.push([arr[i - 1], arr[i]]); + } + return result; +}; + +const fillIntensitiesInIntervals = (intervals: Interval[]): Interval[] => { + if (intervals.length <= 1) { + if (intervals.length === 1 && intervals[0].intensity instanceof RangeIntensityEnd) { + throw new Error("range-intensity-end interval can't be the first interval"); + } + return intervals; + } + + return [intervals[0], ...pairs(intervals).map(([prev, curr]) => fillIntensities(prev, curr))]; +}; + +export const fillRangeIntensities = (workout: Workout): Workout => { + return { + ...workout, + intervals: fillIntensitiesInIntervals(workout.intervals), + }; +}; diff --git a/src/parser/index.ts b/src/parser/index.ts index 8ce650d..c9a75eb 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,6 +1,7 @@ import { Workout } from "../ast"; +import { fillRangeIntensities } from "./fillRangeIntensities"; import { parseTokens } from "./parser"; import { tokenize } from "./tokenizer"; import { validate } from "./validate"; -export const parse = (source: string): Workout => validate(parseTokens(tokenize(source))); +export const parse = (source: string): Workout => validate(fillRangeIntensities(parseTokens(tokenize(source)))); diff --git a/src/parser/parser.test.ts b/src/parser/parser.test.ts index 9d3c5c5..e4fb72d 100644 --- a/src/parser/parser.test.ts +++ b/src/parser/parser.test.ts @@ -275,6 +275,131 @@ Ramp: 5:30 90%..100% `); }); + it("parses end-of-range intervals after power-range interval", () => { + expect( + parse(` +Name: My Workout + +Ramp: 5:00 50%..75% +Ramp: 5:00 ..100% +Ramp: 5:00 ..50% +`).intervals, + ).toMatchInlineSnapshot(` + Array [ + Object { + "cadence": undefined, + "comments": Array [], + "duration": Duration { + "seconds": 300, + }, + "intensity": RangeIntensity { + "_end": 0.75, + "_start": 0.5, + }, + "type": "Ramp", + }, + Object { + "cadence": undefined, + "comments": Array [], + "duration": Duration { + "seconds": 300, + }, + "intensity": RangeIntensity { + "_end": 1, + "_start": 0.75, + }, + "type": "Ramp", + }, + Object { + "cadence": undefined, + "comments": Array [], + "duration": Duration { + "seconds": 300, + }, + "intensity": RangeIntensity { + "_end": 0.5, + "_start": 1, + }, + "type": "Ramp", + }, + ] + `); + }); + + it("parses end-of-range intervals after normal interval", () => { + expect( + parse(` +Name: My Workout + +Interval: 5:00 75% +Ramp: 5:00 ..100% +Ramp: 5:00 ..50% +`).intervals, + ).toMatchInlineSnapshot(` + Array [ + Object { + "cadence": undefined, + "comments": Array [], + "duration": Duration { + "seconds": 300, + }, + "intensity": ConstantIntensity { + "_value": 0.75, + }, + "type": "Interval", + }, + Object { + "cadence": undefined, + "comments": Array [], + "duration": Duration { + "seconds": 300, + }, + "intensity": RangeIntensity { + "_end": 1, + "_start": 0.75, + }, + "type": "Ramp", + }, + Object { + "cadence": undefined, + "comments": Array [], + "duration": Duration { + "seconds": 300, + }, + "intensity": RangeIntensity { + "_end": 0.5, + "_start": 1, + }, + "type": "Ramp", + }, + ] + `); + }); + + it("throws error when end-of-range intervals after free-ride interval", () => { + expect( + () => + parse(` +Name: My Workout + +FreeRide: 5:00 +Ramp: 5:00 ..100% +Ramp: 5:00 ..50% +`).intervals, + ).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be after free-intensity interval"`); + }); + + it("throws error when end-of-range intervals is the first interval", () => { + expect( + () => + parse(` +Name: My Workout + +Ramp: 5:00 ..50% +`).intervals, + ).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be the first interval"`); + }); + it("parses free-ride intervals", () => { expect( parse(` diff --git a/src/parser/parser.ts b/src/parser/parser.ts index bb886ed..3ae1b6a 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1,7 +1,7 @@ import { last } from "ramda"; import { Interval, Workout, Comment } from "../ast"; import { Duration } from "../Duration"; -import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity"; +import { ConstantIntensity, FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity"; import { ParseError } from "./ParseError"; import { IntervalType, OffsetToken, SourceLocation, Token } from "./tokenizer"; @@ -158,6 +158,9 @@ const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLoc } else if (token.type === "intensity-range") { intensity = new RangeIntensity(token.value[0], token.value[1]); tokens.shift(); + } else if (token.type === "intensity-range-end") { + intensity = new RangeIntensityEnd(token.value); + tokens.shift(); } else { break; } diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 9ee978a..9678ae6 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -47,6 +47,11 @@ export type RangeIntensityToken = { value: [number, number]; loc: SourceLocation; }; +export type RangeIntensityEndToken = { + type: "intensity-range-end"; + value: number; + loc: SourceLocation; +}; export type CommentStartToken = { type: "comment-start"; value?: undefined; @@ -59,6 +64,7 @@ export type Token = | NumberToken | OffsetToken | RangeIntensityToken + | RangeIntensityEndToken | CommentStartToken; const toInteger = (str: string): number => { @@ -81,10 +87,14 @@ const tokenizeValueParam = (text: string, loc: SourceLocation): Token => { if (/^[0-9]+rpm$/.test(text)) { return { type: "cadence", value: toInteger(text), loc }; } - if (/^[0-9]+%..[0-9]+%$/.test(text)) { + if (/^[0-9]+%\.\.[0-9]+%$/.test(text)) { const [from, to] = text.split("..").map(toInteger).map(toFraction); return { type: "intensity-range", value: [from, to], loc }; } + if (/^\.\.[0-9]+%$/.test(text)) { + const [, /* _ */ to] = text.split("..").map(toInteger).map(toFraction); + return { type: "intensity-range-end", value: to, loc }; + } if (/^[0-9]+%$/.test(text)) { return { type: "intensity", value: toFraction(toInteger(text)), loc }; }