diff --git a/.gitignore b/.gitignore index 9c07d4a..e051c58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ *.class *.log +.idea +project/target +target diff --git a/README b/README new file mode 100644 index 0000000..49c3c73 --- /dev/null +++ b/README @@ -0,0 +1,6 @@ +Workout manager +================================= + +Defines and schedules workouts + + diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..04d21d3 --- /dev/null +++ b/build.sbt @@ -0,0 +1,9 @@ +name := "workouts" + +version := "0.1" + +scalaVersion := "2.12.4" + +libraryDependencies ++= Seq( + "org.scalatest" %% "scalatest" % "3.0.5" % "test" +) \ No newline at end of file diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..826c0bd --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version = 0.13.16 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..3a8b7aa --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.2") diff --git a/src/main/scala/com.github.mgifos.workouts/model/Duration.scala b/src/main/scala/com.github.mgifos.workouts/model/Duration.scala new file mode 100644 index 0000000..48d4e2f --- /dev/null +++ b/src/main/scala/com.github.mgifos.workouts/model/Duration.scala @@ -0,0 +1,26 @@ +package com.github.mgifos.workouts.model + +import com.github.mgifos.workouts.model.DistanceUnits.DistanceUnit + +sealed trait Duration + +case class DistanceDuration(distance: Float, unit: DistanceUnit) extends Duration + +object DistanceUnits extends Enumeration { + type DistanceUnit = Value + val km, mi, m = Value +} +case class TimeDuration(hours: Int = 0, minutes: Int = 0, seconds: Int = 0) extends Duration + +object Duration { + + private val DistanceRx = """^(\d+([\.]\d+)?)\s*(km|mi|m)$""".r + private val MinutesRx = """^(\d{1,3}):(\d{2})$""".r + + def parse(x: String): Duration = x match { + case DistanceRx(quantity, _, unit) => DistanceDuration(quantity.toFloat, DistanceUnits.withName(unit)) + case MinutesRx(minutes, seconds) => TimeDuration(minutes = minutes.toInt, seconds = seconds.toInt) + 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 new file mode 100644 index 0000000..5448f44 --- /dev/null +++ b/src/main/scala/com.github.mgifos.workouts/model/Step.scala @@ -0,0 +1,58 @@ +package com.github.mgifos.workouts.model + +sealed trait Step { + def `type`: String +} + +abstract class DurationStep(stepType: String, duration: Duration, target: Option[Target] = None) extends Step { + override def `type` = stepType +} + +case class WarmupStep(duration: Duration, target: Option[Target] = None) extends DurationStep("warmup", duration) + +case class RunStep(duration: Duration, target: Option[Target] = None) extends DurationStep("run", duration) + +case class RecoverStep(duration: Duration, target: Option[Target] = None) extends DurationStep("recover", duration) + +case class CooldownStep(duration: Duration, target: Option[Target] = None) extends DurationStep("cooldown", duration) + +case class RepeatStep(count: Int, definition: Seq[DurationStep]) extends Step { + override def `type` = "repeat" +} + +object Step { + + private val StepRx = """^(-\s\w*:\s.*)((\n\s{1,}-\s.*)*)$""".r + private val StepHeader = """^\s*-\s*(\w*):(.*)$""".r + private val ParamsRx = """^([\w\.:\s]+)\s*(@(.*))?$""".r + + def parse(x: String): 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") + RepeatStep(params.trim.toInt, subSteps.trim.lines.toList.map(parseDurationStep)) + case _ => throw new IllegalArgumentException(s"Cannot parse repeat step $header") + } + case StepRx(header, "", null) => parseDurationStep(header) + case _ => throw new IllegalArgumentException(s"Cannot parse step:$x") + } + + 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 "recover" => RecoverStep.tupled(expect(params)) + case "cooldown" => CooldownStep.tupled(expect(params)) + case _ => throw new IllegalArgumentException(s"Duration step type was expected, $name") + } + case _ => throw new IllegalArgumentException(s"Cannot parse step type $x") + } + + private def expect(x: String): (Duration, Option[Target]) = x match { + case ParamsRx(duration, _, target) => + val maybeTarget = Option(target).filter(_.trim.nonEmpty).map(Target.parse) + (Duration.parse(duration.trim), maybeTarget) + case _ => throw new IllegalArgumentException(s"Cannot parse step parameters $x") + } +} + diff --git a/src/main/scala/com.github.mgifos.workouts/model/Target.scala b/src/main/scala/com.github.mgifos.workouts/model/Target.scala new file mode 100644 index 0000000..50f7f30 --- /dev/null +++ b/src/main/scala/com.github.mgifos.workouts/model/Target.scala @@ -0,0 +1,22 @@ +package com.github.mgifos.workouts.model + +sealed trait Target + +case class HrZoneTarget(zone: Int) extends Target +case class PaceTarget(from: Pace, to: Pace) extends Target + +case class Pace(exp: String) { + def minutes: Int = exp.trim.takeWhile(_ != ':').toInt + def seconds: Int = exp.trim.split(":").last.toInt +} + +object Target { + private val HrZoneRx = """^z(\d)$""".r + private val PaceRangeRx = """^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$""".r + + def parse(x: String): Target = x.trim match { + case HrZoneRx(zone) => HrZoneTarget(zone.toInt) + case PaceRangeRx(from, to) => PaceTarget(Pace(from), Pace(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/Workout.scala b/src/main/scala/com.github.mgifos.workouts/model/Workout.scala new file mode 100644 index 0000000..d39a0ad --- /dev/null +++ b/src/main/scala/com.github.mgifos.workouts/model/Workout.scala @@ -0,0 +1,26 @@ +package com.github.mgifos.workouts.model + +case class Workout(name: String, steps: Seq[Step] = Nil) { + def withStep(step: Step): Workout = Workout(name, steps :+ step) +} + +object Workout { + + private val WorkoutName = """^workout:\s([\w-]+)((\n\s*-\s[a-z]+:.*)*)$""".r + private val NextStepRx = """^((-\s\w*:\s.*)((\n\s{1,}-\s.*)*))(([\s].*)*)$""".r + + def parse(x: String) = { + def loop(w: Workout, steps: String): Workout = steps match { + case NextStepRx(next, _, _, _, rest, _) => + val newWorkout = w.withStep(Step.parse(next.trim)) + if (rest.trim.isEmpty) newWorkout + else loop(newWorkout, rest.trim) + case _ => throw new IllegalArgumentException(s"Input string cannot be parsed to Workout: $steps") + } + x match { + case WorkoutName(name, steps, _) => + loop(Workout(name), steps.trim) + case _ => throw new IllegalArgumentException(s"Input string cannot be parsed to Workout: $x") + } + } +} diff --git a/src/test/scala/com/github/mgifos/workouts/model/DurationSpec.scala b/src/test/scala/com/github/mgifos/workouts/model/DurationSpec.scala new file mode 100644 index 0000000..4e6a572 --- /dev/null +++ b/src/test/scala/com/github/mgifos/workouts/model/DurationSpec.scala @@ -0,0 +1,30 @@ +package com.github.mgifos.workouts.model + +import org.scalatest.{ FlatSpec, Matchers } +import com.github.mgifos.workouts.model.DistanceUnits._ + +class DurationSpec extends FlatSpec with Matchers { + + "Duration" should "parse correctly" in { + + a[IllegalArgumentException] should be thrownBy Duration.parse("") + a[IllegalArgumentException] should be thrownBy Duration.parse("5") + a[IllegalArgumentException] should be thrownBy Duration.parse("5k") + a[IllegalArgumentException] should be thrownBy Duration.parse("5 k") + a[IllegalArgumentException] should be thrownBy Duration.parse(":") + a[IllegalArgumentException] should be thrownBy Duration.parse("1:") + a[IllegalArgumentException] should be thrownBy Duration.parse("1:0") + a[IllegalArgumentException] should be thrownBy Duration.parse("01: 00") + a[IllegalArgumentException] should be thrownBy Duration.parse("00") + a[IllegalArgumentException] should be thrownBy Duration.parse(":00") + a[IllegalArgumentException] should be thrownBy Duration.parse("1234:00") + a[IllegalArgumentException] should be thrownBy Duration.parse("2:40:15") + + Duration.parse("5km") should be(DistanceDuration(5, km)) + Duration.parse("10 mi") should be(DistanceDuration(10, mi)) + Duration.parse("7.5m") should be(DistanceDuration(7.5f, m)) + + Duration.parse("1:00") should be(TimeDuration(minutes = 1)) + Duration.parse("123:00") should be(TimeDuration(minutes = 123)) + } +} diff --git a/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala b/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala new file mode 100644 index 0000000..8f75398 --- /dev/null +++ b/src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala @@ -0,0 +1,21 @@ +package com.github.mgifos.workouts.model + +import com.github.mgifos.workouts.model.DistanceUnits._ +import org.scalatest.{ FlatSpec, Matchers } + +class StepSpec extends FlatSpec with Matchers { + + "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(RunStep(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)), + RecoverStep(DistanceDuration(100, m))))) + } +} diff --git a/src/test/scala/com/github/mgifos/workouts/model/TargetSpec.scala b/src/test/scala/com/github/mgifos/workouts/model/TargetSpec.scala new file mode 100644 index 0000000..2c18e07 --- /dev/null +++ b/src/test/scala/com/github/mgifos/workouts/model/TargetSpec.scala @@ -0,0 +1,19 @@ +package com.github.mgifos.workouts.model + +import org.scalatest.{ FlatSpec, Matchers } + +class TargetSpec extends FlatSpec with Matchers { + + "Target" should "parse correctly" in { + + a[IllegalArgumentException] should be thrownBy Target.parse("") + + Target.parse("z1") should be(HrZoneTarget(1)) + Target.parse("z2") should be(HrZoneTarget(2)) + + 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"))) + } +} diff --git a/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala b/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala new file mode 100644 index 0000000..914cd6d --- /dev/null +++ b/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala @@ -0,0 +1,32 @@ +package com.github.mgifos.workouts.model + +import org.scalatest.{ FlatSpec, Matchers } +import com.github.mgifos.workouts.model.DistanceUnits._ + +class WorkoutSpec extends FlatSpec with Matchers { + + "Workout" should "parse correctly" in { + a[IllegalArgumentException] should be thrownBy Workout.parse("") + a[IllegalArgumentException] should be thrownBy Workout.parse("workout") + a[IllegalArgumentException] should be thrownBy Workout.parse("workout:") + a[IllegalArgumentException] should be thrownBy Workout.parse("workout: !") + a[IllegalArgumentException] should be thrownBy Workout.parse("workout run-fast") + a[IllegalArgumentException] should be thrownBy Workout.parse(" workout: run-fast") + /* + workout: run-fast + - warmup: 10:00 + - repeat: 2 + - run: 1500m @ z4 + - recover: 01:30 @ z2 + - cooldown: 5:00 + */ + val testWO = "workout: run-fast\n- warmup: 10:00\n- repeat: 2\n - run: 1500m @ z4\n - recover: 01:30 @ z2\n- cooldown: 5:00" + Workout.parse(testWO) should be( + Workout("run-fast", Seq( + WarmupStep(TimeDuration(minutes = 10)), + RepeatStep(2, Seq( + RunStep(DistanceDuration(1500, m), Some(HrZoneTarget(4))), + RecoverStep(TimeDuration(0, 1, 30), Some(HrZoneTarget(2))))), + CooldownStep(TimeDuration(minutes = 5))))) + } +}