diff --git a/README.md b/README.md index 27a87d3..57d98d2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ is a command line tool to define, import, schedule and share GarminConnect worko An example of a workout definition notation: ```sh -workout: 15k, 3x3.2k @HMP +running: 15k, 3x3.2k @HMP - warmup: 2km @z2 - repeat: 3 - run: 3200m @ 5:05-4:50 @@ -12,7 +12,7 @@ workout: 15k, 3x3.2k @HMP - cooldown: lap-button ``` and the tool's job is actually to translate it to this: -![15k workout](https://i.imgur.com/vxXNV7w.png) +![15k workout](https://raw.githubusercontent.com/mgifos/quick-plan/master/images/15k-wo.png) ## File format @@ -22,10 +22,10 @@ An example of 2-weeks training plan, containing 2 workout definitions, 4 referen | Week | Mon | Tue | Wed | Thu | Fri | Sat | Sun | | ----:| --- | --- | --- | --- | --- | --- | --- | -| 1 | ``workout: run-fast``
``- warmup: 10:00 @ z2``
``- repeat: 3``
  ``- run: 1.5km @ 5:10-4:40``
  ``- recover: 500m @ z2``
``- cooldown: 05:00``|rest|rest|run-fast|rest|rest|rest| -| 2 | run-fast| ``workout: long-15``
``- run: 15 km @ z2``|rest|run-fast|rest|rest|long-15| +| 1 | ``running: run-fast``
``- warmup: 10:00 @ z2``
``- repeat: 3``
  ``- run: 1.5km @ 5:10-4:40``
  ``- recover: 500m @ z2``
``- cooldown: 05:00``|rest|rest|run-fast|rest|rest|rest| +| 2 | run-fast| ``cycling: cycle-wo``
``- bike: 15 km @ 20.0-30kph``|rest|run-fast|rest|rest|cycle-wo| -Checkout a [complete training plan for 80K ultra](https://docs.google.com/spreadsheets/d/1b1ZzrAFrjd-kvPq11zlbE2bWn2IQmUy0lBqIOFjqbwk/edit?usp=sharing). It was originally published in an article of Runner's world website - here's [the link](https://www.runnersworld.com/ultrarunning/the-ultimate-ultramarathon-training-plan). +Checkout a [complete training plan for 80K ultra](https://docs.google.com/spreadsheets/d/1b1ZzrAFrjd-kvPq11zlbE2bWn2IQmUy0lBqIOFjqbwk/edit?usp=sharing). It was originally published in an article on Runner's world website - here's [the link](https://www.runnersworld.com/ultrarunning/the-ultimate-ultramarathon-training-plan). ## Installation @@ -67,11 +67,13 @@ quick-plan schedule -n 2018-04-29 -x -e your-mail-address@example.com ultra-80k- ``` ## Workout notation -The reserved keywords of the notation are: workout, warmup, cooldown, run, repeat, recover and lap-button. +The reserved keywords of the notation are: workout, warmup, cooldown, run, bike, repeat, recover and lap-button. **``** := `
+` -**`
`** := `workout: ` +**`
`** := `: ` + +**``** := (running | cycling) **``** := `[\u0020-\u007F]+` (printable ascii characters) @@ -79,7 +81,7 @@ The reserved keywords of the notation are: workout, warmup, cooldown, run, repea **``** := ` | ` -**``** := `(warmup | cooldown | run | recover): [@ ]` +**``** := `(warmup | cooldown | run | bike | recover): [@ ]` **``** := `repeat: ( - )+` @@ -95,8 +97,12 @@ The reserved keywords of the notation are: workout, warmup, cooldown, run, repea **``** := ` - ` +**``** := ` - kph` + **``** := `:` +**``** := `\d{1,3}(\.\d)?` + **``** := `\d{1,2}` **``** := `\d{2}` \ No newline at end of file diff --git a/images/15k-wo.png b/images/15k-wo.png new file mode 100644 index 0000000..9bcec7c Binary files /dev/null and b/images/15k-wo.png differ diff --git a/src/main/scala/com.github.mgifos.workouts/model/Step.scala b/src/main/scala/com.github.mgifos.workouts/model/Step.scala index 5c64943..39a1114 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Step.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Step.scala @@ -29,7 +29,7 @@ case class WarmupStep(duration: Duration, target: Option[Target] = None) extends case class CooldownStep(duration: Duration, target: Option[Target] = None) extends DurationStep("cooldown", 2) -case class RunStep(duration: Duration, target: Option[Target] = None) extends DurationStep("interval", 3) +case class IntervalStep(duration: Duration, target: Option[Target] = None) extends DurationStep("interval", 3) case class RecoverStep(duration: Duration, target: Option[Target] = None) extends DurationStep("recovery", 4) @@ -70,7 +70,7 @@ object Step { private def parseDurationStep(x: String): DurationStep = x match { case StepHeader(name, params) => name match { case "warmup" => WarmupStep.tupled(expect(params)) - case "run" => RunStep.tupled(expect(params)) + case "run" | "bike" => IntervalStep.tupled(expect(params)) case "recover" => RecoverStep.tupled(expect(params)) case "cooldown" => CooldownStep.tupled(expect(params)) case _ => throw new IllegalArgumentException(s"Duration step type was expected, $name") diff --git a/src/main/scala/com.github.mgifos.workouts/model/Target.scala b/src/main/scala/com.github.mgifos.workouts/model/Target.scala index 837176d..5aaf12f 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Target.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Target.scala @@ -15,6 +15,7 @@ case class HrZoneTarget(zone: Int) extends Target { "targetValueTwo" -> "", "zoneNumber" -> zone.toString) } + case class PaceTarget(from: Pace, to: Pace) extends Target { override def json = Json.obj( "targetType" -> Json.obj( @@ -25,6 +26,16 @@ case class PaceTarget(from: Pace, to: Pace) extends Target { "zoneNumber" -> JsNull) } +case class SpeedTarget(from: KphSpeed, to: KphSpeed) extends Target { + override def json = Json.obj( + "targetType" -> Json.obj( + "workoutTargetTypeId" -> 5, + "workoutTargetTypeKey" -> "speed.zone"), + "targetValueOne" -> from.speed, + "targetValueTwo" -> to.speed, + "zoneNumber" -> JsNull) +} + object NoTarget extends Target { override def json = Json.obj( "targetType" -> Json.obj( @@ -45,12 +56,22 @@ case class Pace(exp: String) { def speed: Double = 1000D / (minutes * 60 + seconds) } +case class KphSpeed(exp: String) { + + /** + * @return Speed in m/s + */ + def speed: Double = exp.toDouble * 10 / 36 +} + object Target { private val HrZoneRx = """^z(\d)$""".r private val PaceRangeRx = """^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$""".r + private val SpeedRangeRx = """^(\d{1,3}(\.\d{1})?)\s*-\s*(\d{1,3}(\.\d{1})?)\s*kph$""".r def parse(x: String): Target = x.trim match { case HrZoneRx(zone) => HrZoneTarget(zone.toInt) + case SpeedRangeRx(from, _, to, _) => SpeedTarget(KphSpeed(from), KphSpeed(to)) case PaceRangeRx(from, to) => PaceTarget(Pace(from), Pace(to)) case _ => throw new IllegalArgumentException(s"Unknown target specification: $x") } diff --git a/src/main/scala/com.github.mgifos.workouts/model/Workout.scala b/src/main/scala/com.github.mgifos.workouts/model/Workout.scala index 50ea901..84d9af7 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Workout.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Workout.scala @@ -1,25 +1,26 @@ package com.github.mgifos.workouts.model import play.api.libs.json.{ JsValue, Json } +import Workout._ trait Workout { def json: JsValue } -case class WorkoutDef(name: String, steps: Seq[Step] = Nil) extends Workout { +case class WorkoutDef(sport: String, name: String, steps: Seq[Step] = Nil) extends Workout { def toRef: WorkoutRef = WorkoutRef(name) - def withStep(step: Step): WorkoutDef = WorkoutDef(name, steps :+ step) + def withStep(step: Step): WorkoutDef = WorkoutDef(sport, name, steps :+ step) def json: JsValue = Json.obj( "sportType" -> Json.obj( - "sportTypeId" -> 1, - "sportTypeKey" -> "running"), + "sportTypeId" -> sportId(sport), + "sportTypeKey" -> sport), "workoutName" -> name, "workoutSegments" -> Json.arr( Json.obj( "segmentOrder" -> 1, "sportType" -> Json.obj( - "sportTypeId" -> 1, - "sportTypeKey" -> "running"), + "sportTypeId" -> sportId(sport), + "sportTypeKey" -> sport), "workoutSteps" -> steps.zipWithIndex.map { case (s, i) => s.json(i + 1) }))) } @@ -33,7 +34,7 @@ case class WorkoutNote(note: String) extends Workout { object Workout { - private val WorkoutName = """^workout:\s([\u0020-\u007F]+)((\n\s*\-\s[a-z]+:.*)*)$""".r + private val WorkoutHeader = """^(running|cycling):\s([\u0020-\u007F]+)((\n\s*\-\s[a-z]+:.*)*)$""".r private val NextStepRx = """^((-\s\w*:\s.*)((\n\s{1,}-\s.*)*))(([\s].*)*)$""".r def parseDef(x: String): Either[String, WorkoutDef] = { @@ -45,13 +46,19 @@ object Workout { case _ => Left(s"Input string cannot be parsed to Workout: $steps") } x match { - case WorkoutName(name, steps, _) => loop(WorkoutDef(name), steps.trim) + case WorkoutHeader(sport, name, steps, _) => loop(WorkoutDef(sport, name), steps.trim) case _ => Left(s"Input string cannot be parsed to Workout: $x") } } def parseRef(x: String): WorkoutRef = x match { - case WorkoutName(name, _, _) => WorkoutRef(name) + case WorkoutHeader(_, name, _, _) => WorkoutRef(name) case _ => WorkoutRef(x.trim) } + + def sportId(sport: String) = sport match { + case "running" => 1 + case "cycling" => 2 + case _ => throw new IllegalArgumentException("Only running and cycling workouts are supported.") + } } diff --git a/src/test/resources/ultra-80k-runnersworld.csv b/src/test/resources/ultra-80k-runnersworld.csv index cba56d8..e06bd27 100644 --- a/src/test/resources/ultra-80k-runnersworld.csv +++ b/src/test/resources/ultra-80k-runnersworld.csv @@ -1,114 +1,114 @@ WEEK,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday,Estimated km,,Duration,Avg km,Miles -1,,"workout: 14k, 4x 1.6k @TMP +1,,"running: 14k, 4x 1.6k @TMP - warmup: 2km @z2 - repeat: 4 - run: 1600m @ 5:00-4:30 - recover: 900m @z2 - run: 2km -- cooldown: lap-button","workout: 8k jog +- cooldown: lap-button","running: 8k jog - run: 8km @z2 -- cooldown: lap-button","workout: 11-15k, middle 5k @MP +- cooldown: lap-button","running: 11-15k, middle 5k @MP - warmup: 4km @z2 - run: 5km @ 5:40-5:30 - run: 6km @z2 -- cooldown: lap-button",,"workout: 1.5h run +- cooldown: lap-button",,"running: 1.5h run - run: 90:00 -- cooldown: lap-button","workout: 3h run +- cooldown: lap-button","running: 3h run - run: 180:00 - cooldown: lap-button",78.0,,0:10:00,1.7,1.0 2,,"14k, 4x 1.6k @TMP",8k jog,"11-15k, middle 5k @MP",,1.5h run,3h run,78.0,,0:15:00,2.5,1.6 -3,,"workout: 14k, 2x1.6k @HMP +3,,"running: 14k, 2x1.6k @HMP - warmup: 4km @z2 - repeat: 2 - run: 1.6km @ 5:05-4:50 - recover: 1.4km @z2 - run: 4km @z2 -- cooldown: lap-button",8k jog,"11-15k, middle 5k @MP",,"workout: 2h run +- cooldown: lap-button",8k jog,"11-15k, middle 5k @MP",,"running: 2h run - run: 120:00 -- cooldown: lap-button","workout: 3.5h run +- cooldown: lap-button","running: 3.5h run - run: 210:00 - cooldown: lap-button",90.0,,0:30:00,5.0,3.1 -4,,"workout: 10k, 3x1.6k @TMP +4,,"running: 10k, 3x1.6k @TMP - warmup: 1500m @z2 - repeat: 3 - run: 1600m @ 5:00-4:30 - recover: 900m @z2 - run: 1km @z2 -- cooldown: lap-button",8k jog,"workout: 10k, middle 3.2k @MP +- cooldown: lap-button",8k jog,"running: 10k, middle 3.2k @MP - warmup: 4km @z2 - run: 3200m @ 5:40-5:30 - run: 2800m @z2 - cooldown: lap-button",,1.5h run,2h run,63.0,,0:40:00,6.7,4.1 -5,,"workout: 15k, 6x1.6k @TMP +5,,"running: 15k, 6x1.6k @TMP - warmup: 2km @z2 - repeat: 6 - run: 1600m @ 5:00-4:30 - recover: 400m @z2 - run: 1km -- cooldown: lap-button",8k jog,"workout: 15k, middle 5k @MP +- cooldown: lap-button",8k jog,"running: 15k, middle 5k @MP - warmup: 5km @z2 - run: 5km @ 5:40-5:30 - run: 5km @z2 -- cooldown: lap-button",,"workout: 3.5-4h run +- cooldown: lap-button",,"running: 3.5-4h run - run 225:00 - cooldown: lap-button",3h run,104.7,,0:45:00,7.5,4.7 6,,"15k, 6x1.6k @TMP",8k jog,"15k, middle 5k @MP",,3.5-4h run,3h run,104.7,,0:50:00,8.3,5.2 -7,,"workout: 15k, 6x1.6k @HMP +7,,"running: 15k, 6x1.6k @HMP - warmup: 2km @z2 - repeat: 6 - run: 1600m @ 5:05-4:50 - recover: 400m @z2 - run: 1km -- cooldown: lap-button",8k jog,"15k, middle 5k @MP",,3.5-4h run,"workout: 3h run, last @MP +- cooldown: lap-button",8k jog,"15k, middle 5k @MP",,3.5-4h run,"running: 3h run, last @MP - run: 120:00 - run: 60:00 @ 5:40-5:30 - cooldown: lap-button",104.7,,1:00:00,10.0,6.2 -8,,"workout: 15k, 3x3.2k @HMP +8,,"running: 15k, 3x3.2k @HMP - warmup: 2km @z2 - repeat: 3 - run: 3200m @ 5:05-4:50 - recover: 800m @z2 - run: 1km @z2 -- cooldown: lap-button",8k jog,"15k, middle 5k @MP",,2h run,"workout: 2.5h run +- cooldown: lap-button",8k jog,"15k, middle 5k @MP",,2h run,"running: 2.5h run - run: 150:00 - cooldown: lap-button",83.0,,1:20:00,13.3,8.3 -9,,"15k, 6x1.6k @TMP",8k jog,"15k, middle 5k @MP",,"workout: 4h run +9,,"15k, 6x1.6k @TMP",8k jog,"15k, middle 5k @MP",,"running: 4h run - run: 240:00 -- cooldown: lap-button","workout: 3.5h run, last @MP +- cooldown: lap-button","running: 3.5h run, last @MP - run: 150:00 - run: 60:00 @ 5:40-5:30 - cooldown: lap-button",113.0,,1:30:00,15.0,9.3 10,,"15k, 6x1.6k @TMP",8k jog,"15k, middle 5k @MP",,4-hour run,"3.5h run, last @MP",113.0,,1:45:00,17.5,10.9 11,,"15k, 3x3.2k @HMP",8k jog,"15k, middle 5k @MP",,2.5h run,3h run,93.0,,2:00:00,20.0,12.4 -12,,"15k, 6x1.6k @TMP",8k jog,"15k, middle 5k @MP",,4h run,"workout: 5h run +12,,"15k, 6x1.6k @TMP",8k jog,"15k, middle 5k @MP",,4h run,"running: 5h run - run: 300:00 - cooldown: lap-button",128.0,,2:30:00,25.0,15.5 13,,"15k, 6x1.6k @TMP",8k jog,"15k, middle 5k @MP",,4h run,5h run,128.0,,3:00:00,30.0,18.6 -14,,"workout: 15k, 4x1.6k @TMP +14,,"running: 15k, 4x1.6k @TMP - warmup: 3km @z2 - repeat: 4 - run: 1600m @ 5:00-4:30 - recover: 1400m @z2 - run: 1km @z2 - cooldown: lap-button",8k jog,"15k, middle 5k @MP",,2h run,2h run,78.0,,3:30:00,35.0,21.7 -15,,"workout: 11k, 3x1.6k @MP +15,,"running: 11k, 3x1.6k @MP - warmup: 1.5km @z2 - repeat: 3 - run: 1600m @ 5:40-5:30 - recover: 1400m @z2 - run: 500m @z2 -- cooldown: lap-button",8k jog,"workout: 11k, middle 5k @MP +- cooldown: lap-button",8k jog,"running: 11k, middle 5k @MP - warmup: 3km @z2 - run: 5km @ 5:40-5:30 - run: 3km @z2 -- cooldown: lap-button",,1.5h run,"workout: easy 1h jog +- cooldown: lap-button",,1.5h run,"running: easy 1h jog - run: 60:00 - cooldown: lap-button",55.0,,4:00:00,40.0,24.8 -16,,"workout: 10k, middle 5k @HMP +16,,"running: 10k, middle 5k @HMP - warmup: 3km @z2 - run: 5km @ 5:05-4:30 - run: 2km @z2 -- cooldown: lap-button",8k jog,"workout: easy 5k jog +- cooldown: lap-button",8k jog,"running: easy 5k jog - run: 5km - cooldown: lap-button",,Race day!,,103.0,,5:00:00,50.0,31.1 ,,,,,,,,"1,517.0",,,, diff --git a/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala b/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala index 8f75398..d011bc7 100644 --- a/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala +++ b/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala @@ -11,11 +11,11 @@ class StepSpec extends FlatSpec with Matchers { a[AssertionError] should be thrownBy Step.parse("- warmup: 5km\n - run: 10km\n - recover: 100m") Step.parse("- warmup: 5km") should be(WarmupStep(DistanceDuration(5, km))) - Step.parse("- run: 2km @ 5:00-4:50") should be(RunStep(DistanceDuration(2, km), Some(PaceTarget(Pace("5:00"), Pace("4:50"))))) + Step.parse("- run: 2km @ 5:00-4:50") should be(IntervalStep(DistanceDuration(2, km), Some(PaceTarget(Pace("5:00"), Pace("4:50"))))) Step.parse("- recover: 500m @z2") should be(RecoverStep(DistanceDuration(500, m), Some(HrZoneTarget(2)))) Step.parse("- cooldown: 05:00") should be(CooldownStep(TimeDuration(minutes = 5))) Step.parse("- repeat: 3\n - run: 10km\n - recover: 100m") should be(RepeatStep(3, List( - RunStep(DistanceDuration(10, km)), + IntervalStep(DistanceDuration(10, km)), RecoverStep(DistanceDuration(100, m))))) } } diff --git a/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala b/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala index 947efc2..caf1d3b 100644 --- a/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala +++ b/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala @@ -7,29 +7,29 @@ import play.api.libs.json.Json class WorkoutSpec extends FlatSpec with Matchers { /* - workout: run-fast + running: run-fast - warmup: 10:00 - repeat: 2 - run: 1500m @ 4:30-5:00 - recover: 01:30 @ z2 - cooldown: lap-button */ - val testWO = "workout: run-fast\n- warmup: 10:00\n- repeat: 2\n - run: 1500m @ 4:30-5:00\n - recover: 01:30 @ z2\n- cooldown: lap-button" + val testWO = "running: run-fast\n- warmup: 10:00\n- repeat: 2\n - run: 1500m @ 4:30-5:00\n - recover: 01:30 @ z2\n- cooldown: lap-button" "Workout" should "parse correctly" in { Workout.parseDef("") should be('left) - Workout.parseDef("workout") should be('left) - Workout.parseDef("workout:") should be('left) - Workout.parseDef("workout: !") should be('left) - Workout.parseDef("workout run-fast") should be('left) - Workout.parseDef(" workout: run-fast") should be('left) + Workout.parseDef("running") should be('left) + Workout.parseDef("running:") should be('left) + Workout.parseDef("running !") should be('left) + Workout.parseDef("running run-fast") should be('left) + Workout.parseDef(" running: run-fast") should be('left) Workout.parseDef(testWO) should be( Right( - WorkoutDef("run-fast", Seq( + WorkoutDef("running", "run-fast", Seq( WarmupStep(TimeDuration(minutes = 10)), RepeatStep(2, Seq( - RunStep(DistanceDuration(1500, m), Some(PaceTarget(Pace("4:30"), Pace("5:00")))), + IntervalStep(DistanceDuration(1500, m), Some(PaceTarget(Pace("4:30"), Pace("5:00")))), RecoverStep(TimeDuration(1, 30), Some(HrZoneTarget(2))))), CooldownStep(LapButtonPressed))))) } @@ -45,10 +45,19 @@ class WorkoutSpec extends FlatSpec with Matchers { } } - "Workout" should "dump json correctly" in { val is = getClass.getClassLoader.getResourceAsStream("run-fast.json") val expectJson = Json.parse(is) Workout.parseDef(testWO).map(_.json) should be(Right(expectJson)) } + + "Workout" should "support cycling ws" in { + val testBike = "cycling: cycle-test\n- warmup: 5:00\n- bike: 20km @ 20.0-100kph\n- cooldown: lap-button" + Workout.parseDef(testBike) should be( + Right( + WorkoutDef("cycling", "cycle-test", Seq( + WarmupStep(TimeDuration(minutes = 5)), + IntervalStep(DistanceDuration(20, km), Some(SpeedTarget(KphSpeed("20.0"), KphSpeed("100")))), + CooldownStep(LapButtonPressed))))) + } }