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))))))
}
}