diff --git a/README b/README deleted file mode 100644 index 49c3c73..0000000 --- a/README +++ /dev/null @@ -1,6 +0,0 @@ -Workout manager -================================= - -Defines and schedules workouts - - diff --git a/README.md b/README.md new file mode 100644 index 0000000..f35258a --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Workouts +The objectives of this project are to: + - enable easier notation for workouts (textual form) + - be able to specify weekly based training plans in e.g. spreadsheets that can be exported to CSV + - be able to import workouts from CSV file to Garmin Connect workout list + - be able to schedule imported workouts in Garmin Connect calendar automaticaly based on start/end date + +An example of a workout definition notation: +```sh +workout: run-fast +- warmup: 10:00 @ z2 +- repeat: 3 + - run: 1.5km @ 5:10-4:40 + - recover: 500m @ z2 +- cooldown: 05:00 +``` +In CSV 1st row is reserved for heading, can be anything and the 1st column is reserved for a week number. The cells represent days and they are populated with workouts (definitions and references), so there's a limitation: a single work out per day/cell, for now. In case of the references, a workout needs to be defined first in one of the previous cells and then it can be referenced by name in the following cells. + +An example of 2-weeks training plan, containing 2 workout definitions, 4 references and 6 training days in total: + +| 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| \ No newline at end of file diff --git a/build.sbt b/build.sbt index 04d21d3..35c3574 100644 --- a/build.sbt +++ b/build.sbt @@ -5,5 +5,6 @@ version := "0.1" scalaVersion := "2.12.4" libraryDependencies ++= Seq( + "com.github.tototoshi" %% "scala-csv" % "1.3.5", "org.scalatest" %% "scalatest" % "3.0.5" % "test" ) \ 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 new file mode 100644 index 0000000..80c4eb8 --- /dev/null +++ b/src/main/scala/com.github.mgifos.workouts/Main.scala @@ -0,0 +1,23 @@ +package com.github.mgifos.workouts + +import java.nio.file.{ Files, Paths } +import java.time.LocalDate + +import com.github.mgifos.workouts.model.WeeklyPlan + +object Main extends App { + + val csvBytes = Files.readAllBytes(Paths.get("src/test/resources/ultra-80k-runnersworld.csv")) + + val wp = new WeeklyPlan(csvBytes) + + println(wp.workouts) + + val x = LocalDate.now() + + val it = wp.get.zipWithIndex.map { + case (maybeWorkout, i) => x.plusDays(i) -> maybeWorkout + } + + it.zipWithIndex.map { case (maybeScheduledWorkout, i) => s"day $i: $maybeScheduledWorkout" }.foreach(println) +} 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 48d4e2f..d3c5c8d 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Duration.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Duration.scala @@ -10,6 +10,7 @@ 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 { diff --git a/src/main/scala/com.github.mgifos.workouts/model/Week.scala b/src/main/scala/com.github.mgifos.workouts/model/Week.scala new file mode 100644 index 0000000..57539ec --- /dev/null +++ b/src/main/scala/com.github.mgifos.workouts/model/Week.scala @@ -0,0 +1,6 @@ +package com.github.mgifos.workouts.model + +object Week extends Enumeration { + type Week = Value + val Mon, Tue, Wed, Thu, Fri, Sat, Sun = Value +} \ 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 new file mode 100644 index 0000000..5e1b459 --- /dev/null +++ b/src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala @@ -0,0 +1,50 @@ +package com.github.mgifos.workouts.model + +import com.github.tototoshi.csv.CSVReader + +import scala.io.Source + +class WeeklyPlan(csv: Array[Byte]) { + + type Week = List[String] + + private lazy val processed: Seq[Option[Workout]] = { + + def weekPlan(week: Week, acc: Seq[Option[Workout]]): Seq[Option[Workout]] = Seq.tabulate(7) { weekDayNo => + val maybeDayText = week.lift(weekDayNo + 1).flatMap(text => Option(text.trim).filter(_.nonEmpty)) + maybeDayText.map { dayText => + Workout.parseDef(dayText) match { + case Right(definition) => definition + case Left(_) => onlyDefs(acc).find(_.name == dayText).map(_.toRef).getOrElse(WorkoutNote(dayText)) + } + } + } + + def loop(weeks: List[Week], acc: Seq[Option[Workout]]): Seq[Option[Workout]] = weeks match { + case Nil => acc + case week :: rest => loop(rest, acc ++ weekPlan(week, acc)) + } + + loop(CSVReader.open(Source.fromBytes(csv)).all.filter(isAValidWeek), Seq()) + } + + /** + * @return all workout definitions defined in this plan + */ + def workouts: Seq[WorkoutDef] = onlyDefs(processed) + + /** + * @return optional workout refs & notes of this plan per day + */ + def get: Seq[Option[Workout]] = processed.map { + case Some(x: WorkoutDef) => Some(x.toRef) + case x => x + } + + private def isAValidWeek(w: Seq[String]) = w.headOption.exists(no => no.trim.nonEmpty && no.forall(_.isDigit)) + + private def onlyDefs(days: Seq[Option[Workout]]) = days.flatMap { + case Some(wdef: WorkoutDef) => Some(wdef) + case _ => None + } +} \ 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 index d39a0ad..2870385 100644 --- a/src/main/scala/com.github.mgifos.workouts/model/Workout.scala +++ b/src/main/scala/com.github.mgifos.workouts/model/Workout.scala @@ -1,26 +1,37 @@ package com.github.mgifos.workouts.model -case class Workout(name: String, steps: Seq[Step] = Nil) { - def withStep(step: Step): Workout = Workout(name, steps :+ step) +trait Workout + +case class WorkoutDef(name: String, steps: Seq[Step] = Nil) extends Workout { + def toRef: WorkoutRef = WorkoutRef(name) + def withStep(step: Step): WorkoutDef = WorkoutDef(name, steps :+ step) } +case class WorkoutRef(name: String) extends Workout + +case class WorkoutNote(note: String) extends Workout + object Workout { - private val WorkoutName = """^workout:\s([\w-]+)((\n\s*-\s[a-z]+:.*)*)$""".r + 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 { + def parseDef(x: String): 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)) - if (rest.trim.isEmpty) newWorkout + if (rest.trim.isEmpty) Right(newWorkout) else loop(newWorkout, rest.trim) - case _ => throw new IllegalArgumentException(s"Input string cannot be parsed to Workout: $steps") + case _ => Left(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") + case WorkoutName(name, steps, _) => loop(WorkoutDef(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 _ => WorkoutRef(x.trim) + } } diff --git a/src/test/resources/ultra-80k-runnersworld.csv b/src/test/resources/ultra-80k-runnersworld.csv new file mode 100644 index 0000000..2530816 --- /dev/null +++ b/src/test/resources/ultra-80k-runnersworld.csv @@ -0,0 +1,91 @@ +WEEK,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday,Estimated km,,Duration,Avg km,Miles +1,,"workout: 14k, 4x 1.6k @TMP +- warmup: 2km @z2 +- repeat: 4 + - run: 1600m @ 5:00-4:30 + - recover: 900m @z2 +- cooldown: 2km","workout: 8k jog +- run: 8km @z2","workout: 11-15k, middle 5k @MP +- warmup: 4km @z2 +- run: 5km @ 5:40-5:30 +- cooldown: 6km @z2",,"workout: 1.5h run +- run: 90:00","workout: 3h run +- run: 180:00",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 +- warmup: 4km @z2 +- repeat: 2 + - run: 1.6km @ 5:05-4:50 + - recover: 1.4km @z2 +- cooldown: 4km @z2",8k jog,"11-15k, middle 5k @MP",,"workout: 2h run +- run 120:00","workout: 3.5h run +- run: 210:00",90.0,,0:30:00,5.0,3.1 +4,,"workout: 10k, 3x1.6k @TMP +- warmup: 1500m @z2 +- repeat: 3 + - run: 1600m @ 5:00-4:30 + - recover: 900m @z2 +- cooldown: 1km",8k jog,"workout: 10k, middle 3.2k @MP +- warmup: 4km @z2 +- run: 3200m @ 5:40-5:30 +- cooldown: 2800m @z2",,1.5h run,2h run,63.0,,0:40:00,6.7,4.1 +5,,"workout: 15k, 6x1.6k @TMP +- warmup: 2km @z2 +- repeat: 6 + - run: 1600m @ 5:00-4:30 + - recover: 400m @z2 +- cooldown: 1km",8k jog,"workout: 15k, middle 5k @MP +- warmup: 5km @z2 +- run: 5km @ 5:40-5:30 +- cooldown: 5km @z2",,"workout: 3.5-4h run +- run 225:00",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 +- warmup: 2km @z2 +- repeat: 6 + - run: 1600m @ 5:05-4:50 + - recover: 400m @z2 +- cooldown: 1km",8k jog,"15k, middle 5k @MP",,3.5-4h run,"workout: 3h run, last @MP +- run: 120:00 +- run: 60:00 @ 5:40-5:30",104.7,,1:00:00,10.0,6.2 +8,,"workout: 15k, 3x3.2k @HMP +- warmup: 2km @z2 +- repeat: 3 + - run: 3200m @ 5:05-4:50 + - recover: 800m @z2 +- cooldown: 1km",8k jog,"15k, middle 5k @MP",,2h run,"workout: 2.5h run +- run: 150:00",83.0,,1:20:00,13.3,8.3 +9,,"15k, 6x1.6k @TMP",8k jog,"15k, middle 5k @MP",,"workout: 4h run +- run: 240:00","workout: 3.5h run, last @MP +- run: 150:00 +- run: 60:00 @ 5:40-5:30",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 +- run: 300:00",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 +- warmup: 3km @z2 +- repeat: 2 + - run: 1600m @ 5:00-4:30 + - recover: 1400m @z2 +- cooldown: 1km",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 +- warmup: 1.5km @z2 +- repeat: 3 + - run: 1600m @ 5:40-5:30 + - recover: 1400m @z2 +- cooldown: 500m",8k jog,"workout: 11k, middle 5k @MP +- warmup: 3km @z2 +- run: 5km @ 5:40-5:30 +- cooldown: 3km @z2",,1.5h run,"workout: easy 1h jog +- run: 60:00",55.0,,4:00:00,40.0,24.8 +16,,"workout: 10k, middle 5k @HMP +- warmup: 3km @z2 +- run: 5km @ 5:05-4:30 +- cooldown: 2km @z2",8k jog,"workout: easy 5k jog +- run: 5km",,80k race,Rest. (Duh.),103.0,,5:00:00,50.0,31.1 +,,,,,,,,"1,517.0",,,, +,TMP,16k pace,,,,,,,,,, +,HMP,Half Marathon Pace,,,,,Avg min/km,0:06:00,,,, +,MP,Marathon Pace,,,,,,,,,, \ No newline at end of file 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 914cd6d..ce4cf75 100644 --- a/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala +++ b/src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala @@ -6,12 +6,13 @@ 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.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: run-fast - warmup: 10:00 @@ -21,12 +22,13 @@ class WorkoutSpec extends FlatSpec with Matchers { - 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))))) + Workout.parseDef(testWO) should be( + Right( + WorkoutDef("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)))))) } }