parent
43ed552093
commit
bd4125a2b8
@ -1,2 +1,5 @@ |
|||||||
*.class |
*.class |
||||||
*.log |
*.log |
||||||
|
.idea |
||||||
|
project/target |
||||||
|
target |
||||||
|
|||||||
@ -0,0 +1,6 @@ |
|||||||
|
Workout manager |
||||||
|
================================= |
||||||
|
|
||||||
|
Defines and schedules workouts |
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,9 @@ |
|||||||
|
name := "workouts" |
||||||
|
|
||||||
|
version := "0.1" |
||||||
|
|
||||||
|
scalaVersion := "2.12.4" |
||||||
|
|
||||||
|
libraryDependencies ++= Seq( |
||||||
|
"org.scalatest" %% "scalatest" % "3.0.5" % "test" |
||||||
|
) |
||||||
@ -0,0 +1 @@ |
|||||||
|
sbt.version = 0.13.16 |
||||||
@ -0,0 +1 @@ |
|||||||
|
addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.2") |
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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)) |
||||||
|
} |
||||||
|
} |
||||||
@ -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))))) |
||||||
|
} |
||||||
|
} |
||||||
@ -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"))) |
||||||
|
} |
||||||
|
} |
||||||
@ -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))))) |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue