parent
43ed552093
commit
bd4125a2b8
@ -1,2 +1,5 @@ |
||||
*.class |
||||
*.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