diff --git a/README.md b/README.md index 62beb1d..06c3765 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Usage: quick-plan [import|schedule] [options] -e, --email E-mail to login to Garmin Connect -p, --password Password to login to Garmin Connect + -m, --measurement_system + "metric" (default) or "imperial" (miles, inches, ...) measurement system choice. -x, --delete Delete all existing workouts with same names as the ones that are going to be imported. --help prints this usage text @@ -95,9 +97,9 @@ The reserved keywords of the notation are: workout, warmup, cooldown, run, bike, **``** := `z[1-6]` -**``** := ` - ` +**``** := ` - (mpk | mpm)?` -**``** := ` - kph` +**``** := ` - (kph | mph)?` **``** := `:` @@ -105,4 +107,15 @@ The reserved keywords of the notation are: workout, warmup, cooldown, run, bike, **``** := `\d{1,3}` -**``** := `\d{2}` \ No newline at end of file +**``** := `\d{2}` + +## Unit of measurements (metric vs imperial) + +As Garmin supports metric and imperial measurement systems, quick-plan can do this as well. There are two ways of usage: +- implicit (through the tool configuration (see the option -m) or +- explicit (it can be specified within the workout definition by using units: + - km vs mi (for distance), + - kph vs mph (for speed) and + - mpk vs mpm (for pace). + +If not specified -m value from configuration will be used ('metric' by default). \ No newline at end of file diff --git a/src/main/scala/com.github.mgifos.workouts/Main.scala b/src/main/scala/com.github.mgifos.workouts/Main.scala index c0c7b2a..030a385 100644 --- a/src/main/scala/com.github.mgifos.workouts/Main.scala +++ b/src/main/scala/com.github.mgifos.workouts/Main.scala @@ -6,6 +6,7 @@ import java.time.LocalDate import akka.actor.ActorSystem import akka.http.scaladsl.Http import akka.stream.ActorMaterializer +import com.github.mgifos.workouts.model._ import com.github.mgifos.workouts.model.WeeklyPlan import com.typesafe.scalalogging.Logger import scopt.OptionParser @@ -22,6 +23,7 @@ object Modes extends Enumeration { case class Config( mode: Modes.Mode = Modes.`import`, + system: MeasurementSystems.MeasurementSystem = MeasurementSystems.metric, csv: String = "", delete: Boolean = false, email: String = "", @@ -34,6 +36,7 @@ object Main extends App { implicit val system: ActorSystem = ActorSystem("quick-plan") implicit val materializer: ActorMaterializer = ActorMaterializer() implicit val executionContext: ExecutionContextExecutor = system.dispatcher + implicit val mSystemRead: scopt.Read[MeasurementSystems.MeasurementSystem] = scopt.Read.reads(MeasurementSystems.named) val log = Logger(getClass) @@ -59,6 +62,8 @@ object Main extends App { opt[String]('p', "password").action((x, c) => c.copy(password = x)).text("Password to login to Garmin Connect") + opt[MeasurementSystems.MeasurementSystem]('m', "measurement_system").action((x, c) => c.copy(system = x)).text(""""metric" (default) or "imperial" (miles, inches, ...) measurement system choice.""") + opt[Unit]('x', "delete").action((_, c) => c.copy(delete = true)).text("Delete all existing workouts with same names as the ones that are going to be imported.") help("help").text("prints this usage text") @@ -110,7 +115,7 @@ object Main extends App { new String(console.readPassword()) } - implicit val plan: WeeklyPlan = new WeeklyPlan(Files.readAllBytes(Paths.get(config.csv))) + implicit val plan: WeeklyPlan = new WeeklyPlan(Files.readAllBytes(Paths.get(config.csv)))(config.system) implicit val garmin: GarminConnect = new GarminConnect(email, password) diff --git a/src/main/scala/com.github.mgifos.workouts/model/Duration.scala b/src/main/scala/com.github.mgifos.workouts/model/Duration.scala index d9de7f4..3362428 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Duration.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Duration.scala @@ -19,18 +19,6 @@ case class DistanceDuration(distance: Float, unit: DistanceUnit) extends Duratio "endConditionZone" -> JsNull) } -object DistanceUnits extends Enumeration { - type DistanceUnit = DistVal - val km = Value("km", "kilometer", _ * 1000F) - val mi = Value("mi", "mile", _ * 1609.344F) - val m = Value("m", "meter", _ * 1F) - - class DistVal(name: String, val fullName: String, val toMeters: (Float) => Float) extends Val(nextId, name) - protected final def Value(name: String, fullName: String, toMeters: (Float) => Float): DistVal = new DistVal(name, fullName, toMeters) - - def named(name: String): DistVal = withName(name).asInstanceOf[DistVal] -} - case class TimeDuration(minutes: Int = 0, seconds: Int = 0) extends Duration { override def json: JsObject = Json.obj( "endCondition" -> Json.obj( @@ -59,7 +47,7 @@ object Duration { private val MinutesRx = """^(\d{1,3}):(\d{2})$""".r def parse(x: String): Duration = x match { - case DistanceRx(quantity, _, unit) => DistanceDuration(quantity.toFloat, DistanceUnits.named(unit)) + case DistanceRx(quantity, _, uom) => DistanceDuration(quantity.toFloat, DistanceUnits.named(uom)) case MinutesRx(minutes, seconds) => TimeDuration(minutes = minutes.toInt, seconds = seconds.toInt) case "lap-button" => LapButtonPressed case _ => throw new IllegalArgumentException(s"Duration cannot be parsed $x") 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 39a1114..7fedf84 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Step.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Step.scala @@ -56,7 +56,7 @@ object Step { private val StepHeader = """^\s*-\s*(\w*):(.*)$""".r private val ParamsRx = """^([\w-\.:\s]+)\s*(@(.*))?$""".r - def parse(x: String): Step = x match { + def parse(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Step = x match { case StepRx(header, subSteps, _) if subSteps.nonEmpty => header match { case StepHeader(name, params) => assert(name == "repeat", "must be 'repeat' if contains sub-steps") @@ -67,7 +67,7 @@ object Step { case _ => throw new IllegalArgumentException(s"Cannot parse step:$x") } - private def parseDurationStep(x: String): DurationStep = x match { + private def parseDurationStep(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): DurationStep = x match { case StepHeader(name, params) => name match { case "warmup" => WarmupStep.tupled(expect(params)) case "run" | "bike" => IntervalStep.tupled(expect(params)) @@ -78,7 +78,7 @@ object Step { case _ => throw new IllegalArgumentException(s"Cannot parse step type $x") } - private def expect(x: String): (Duration, Option[Target]) = x match { + private def expect(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): (Duration, Option[Target]) = x match { case ParamsRx(duration, _, target) => val maybeTarget = Option(target).filter(_.trim.nonEmpty).map(Target.parse) (Duration.parse(duration.trim), maybeTarget) 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 5aaf12f..ffdfb0d 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Target.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Target.scala @@ -26,7 +26,7 @@ case class PaceTarget(from: Pace, to: Pace) extends Target { "zoneNumber" -> JsNull) } -case class SpeedTarget(from: KphSpeed, to: KphSpeed) extends Target { +case class SpeedTarget(from: Speed, to: Speed) extends Target { override def json = Json.obj( "targetType" -> Json.obj( "workoutTargetTypeId" -> 5, @@ -46,33 +46,38 @@ object NoTarget extends Target { "zoneNumber" -> JsNull) } -case class Pace(exp: String) { +case class Pace(uom: DistanceUnits.DistanceUnit, exp: String) { def minutes: Int = exp.trim.takeWhile(_ != ':').toInt def seconds: Int = exp.trim.split(":").last.toInt /** * @return Speed in m/s */ - def speed: Double = 1000D / (minutes * 60 + seconds) + def speed: Double = uom.toMeters(1) / (minutes * 60 + seconds) } -case class KphSpeed(exp: String) { +case class Speed(unit: DistanceUnits.DistanceUnit, exp: String) { /** * @return Speed in m/s */ - def speed: Double = exp.toDouble * 10 / 36 + def speed: Double = unit.toMeters(exp.toDouble) / 3600 } 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 + private val PaceRangeRx = """^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})\s*(mpk|mpm)?$""".r - def parse(x: String): Target = x.trim match { + private val SpeedRangeRx = """^(\d{1,3}(\.\d{1})?)\s*-\s*(\d{1,3}(\.\d{1})?)\s*(kph|mph)?""".r + + def parse(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): 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 SpeedRangeRx(from, _, to, _, uom) => + val du = Option(uom).fold(msys.distance)(DistanceUnits.withSpeedUOM) + SpeedTarget(Speed(du, from), Speed(du, to)) + case PaceRangeRx(from, to, uom) => + val du = Option(uom).fold(msys.distance)(DistanceUnits.withPaceUOM) + PaceTarget(Pace(du, from), Pace(du, to)) case _ => throw new IllegalArgumentException(s"Unknown target specification: $x") } } \ No newline at end of file diff --git a/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala b/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala index 1fb0808..682b5a2 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala @@ -4,7 +4,7 @@ import com.github.tototoshi.csv.CSVReader import scala.io.Source -class WeeklyPlan(csv: Array[Byte]) { +class WeeklyPlan(csv: Array[Byte])(implicit msys: MeasurementSystems.MeasurementSystem) { type Week = List[String] 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 84d9af7..fb0f76b 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Workout.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Workout.scala @@ -37,7 +37,7 @@ object Workout { 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] = { + def parseDef(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Either[String, WorkoutDef] = { def loop(w: WorkoutDef, steps: String): Either[String, WorkoutDef] = steps match { case NextStepRx(next, _, _, _, rest, _) => val newWorkout = w.withStep(Step.parse(next.trim)) diff --git a/src/main/scala/com.github.mgifos.workouts/model/package.scala b/src/main/scala/com.github.mgifos.workouts/model/package.scala new file mode 100644 index 0000000..b7b3183 --- /dev/null +++ b/src/main/scala/com.github.mgifos.workouts/model/package.scala @@ -0,0 +1,37 @@ +package com.github.mgifos.workouts + +package object model { + + object DistanceUnits extends Enumeration { + type DistanceUnit = DistVal + val km = Value("km", "kilometer", _ * 1000F) + val mi = Value("mi", "mile", _ * 1609.344F) + val m = Value("m", "meter", _ * 1F) + + class DistVal(name: String, val fullName: String, val toMeters: (Double) => Double) extends Val(nextId, name) + protected final def Value(name: String, fullName: String, toMeters: (Double) => Double): DistVal = new DistVal(name, fullName, toMeters) + + def named(name: String): DistVal = withName(name).asInstanceOf[DistVal] + def withPaceUOM(paceUom: String): DistVal = paceUom match { + case "mpk" => km + case "mpm" => mi + case _ => throw new IllegalArgumentException(s"No such pace unit of measurement: '$paceUom'") + } + def withSpeedUOM(speedUom: String): DistVal = speedUom match { + case "kph" => km + case "mph" => mi + case _ => throw new IllegalArgumentException(s"No such speed unit of measurement: '$speedUom'") + } + } + + object MeasurementSystems extends Enumeration { + type MeasurementSystem = MSVal + val imperial = Value("imperial", DistanceUnits.mi) + val metric = Value("metric", DistanceUnits.km) + + class MSVal(name: String, val distance: DistanceUnits.DistanceUnit) extends Val(nextId, name) + protected final def Value(name: String, distance: DistanceUnits.DistanceUnit): MSVal = new MSVal(name, distance) + + def named(name: String): MSVal = withName(name).asInstanceOf[MSVal] + } +} diff --git a/src/test/scala/com/github/mgifos/workouts/WeekPlanSpec.scala b/src/test/scala/com/github/mgifos/workouts/WeekPlanSpec.scala index b48567a..c7d69c9 100644 --- a/src/test/scala/com/github/mgifos/workouts/WeekPlanSpec.scala +++ b/src/test/scala/com/github/mgifos/workouts/WeekPlanSpec.scala @@ -5,6 +5,8 @@ import org.scalatest.{ FlatSpec, Matchers } class WeekPlanSpec extends FlatSpec with Matchers { + implicit val msys = MeasurementSystems.metric + val runFast = "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" val runSlow = "running: run-slow\n- warmup: 10:00\n- run: 5km @ z2\n- cooldown: lap-button" val testPlan = s"""1,"$runFast",,run-fast,,run-fast,,,\n2,,run-fast,"$runSlow",run-fast,,run-slow,,""" diff --git a/src/test/scala/com/github/mgifos/workouts/model/DurationSpec.scala b/src/test/scala/com/github/mgifos/workouts/model/DurationSpec.scala index 4e6a572..6c609f9 100644 --- a/src/test/scala/com/github/mgifos/workouts/model/DurationSpec.scala +++ b/src/test/scala/com/github/mgifos/workouts/model/DurationSpec.scala @@ -5,6 +5,8 @@ import com.github.mgifos.workouts.model.DistanceUnits._ class DurationSpec extends FlatSpec with Matchers { + implicit val msys = MeasurementSystems.metric + "Duration" should "parse correctly" in { a[IllegalArgumentException] should be thrownBy Duration.parse("") 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 d011bc7..ab2373c 100644 --- a/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala +++ b/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala @@ -5,13 +5,15 @@ import org.scalatest.{ FlatSpec, Matchers } class StepSpec extends FlatSpec with Matchers { + implicit val msys = MeasurementSystems.metric + "Step" should "parse correctly" in { a[IllegalArgumentException] should be thrownBy Step.parse("") 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(IntervalStep(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(msys.distance, "5:00"), Pace(msys.distance, "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( diff --git a/src/test/scala/com/github/mgifos/workouts/model/TargetSpec.scala b/src/test/scala/com/github/mgifos/workouts/model/TargetSpec.scala index 2c18e07..aff04eb 100644 --- a/src/test/scala/com/github/mgifos/workouts/model/TargetSpec.scala +++ b/src/test/scala/com/github/mgifos/workouts/model/TargetSpec.scala @@ -1,9 +1,12 @@ package com.github.mgifos.workouts.model import org.scalatest.{ FlatSpec, Matchers } +import com.github.mgifos.workouts.model.DistanceUnits._ class TargetSpec extends FlatSpec with Matchers { + implicit val msys = MeasurementSystems.metric + "Target" should "parse correctly" in { a[IllegalArgumentException] should be thrownBy Target.parse("") @@ -14,6 +17,17 @@ class TargetSpec extends FlatSpec with Matchers { val paceTarget = Target.parse("5:20-04:30").asInstanceOf[PaceTarget] paceTarget.from.minutes should be(5) paceTarget.to.seconds should be(30) - paceTarget should be(PaceTarget(Pace("5:20"), Pace("04:30"))) + paceTarget should be(PaceTarget(Pace(msys.distance, "5:20"), Pace(msys.distance, "04:30"))) + } + + "Target" should "parse pace UOMs correctly" in { + val mpk = Target.parse("5:20-04:30 mpk").asInstanceOf[PaceTarget] + mpk should be(PaceTarget(Pace(km, "5:20"), Pace(km, "04:30"))) + + val mpm = Target.parse("4:20-05:30 mpm").asInstanceOf[PaceTarget] + mpm should be(PaceTarget(Pace(mi, "4:20"), Pace(mi, "05:30"))) + mpm.from.speed should be(6.189784592848557D) + + a[IllegalArgumentException] should be thrownBy Target.parse("5:20-04:30 unknownUOM") } } 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 3622ae6..3fdf86d 100644 --- a/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala +++ b/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala @@ -1,11 +1,13 @@ package com.github.mgifos.workouts.model -import org.scalatest.{ FlatSpec, Matchers } import com.github.mgifos.workouts.model.DistanceUnits._ +import org.scalatest.{ FlatSpec, Matchers } import play.api.libs.json.Json class WorkoutSpec extends FlatSpec with Matchers { + implicit val msys = MeasurementSystems.metric + /* running: run-fast - warmup: 10:00 @@ -29,7 +31,7 @@ class WorkoutSpec extends FlatSpec with Matchers { WorkoutDef("running", "run-fast", Seq( WarmupStep(TimeDuration(minutes = 10)), RepeatStep(2, Seq( - IntervalStep(DistanceDuration(1500, m), Some(PaceTarget(Pace("4:30"), Pace("5:00")))), + IntervalStep(DistanceDuration(1500, m), Some(PaceTarget(Pace(msys.distance, "4:30"), Pace(msys.distance, "5:00")))), RecoverStep(TimeDuration(1, 30), Some(HrZoneTarget(2))))), CooldownStep(LapButtonPressed))))) } @@ -56,7 +58,7 @@ class WorkoutSpec extends FlatSpec with Matchers { Right( WorkoutDef("cycling", "cycle-test", Seq( WarmupStep(TimeDuration(minutes = 5)), - IntervalStep(DistanceDuration(20, km), Some(SpeedTarget(KphSpeed("20.0"), KphSpeed("100")))), + IntervalStep(DistanceDuration(20, km), Some(SpeedTarget(Speed(km, "20.0"), Speed(km, "100")))), CooldownStep(LapButtonPressed))))) } }