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:
-
+
## 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)))))
+ }
}