[SUMMARY] Running Coach (#82)

This quiz turns out to be a little bit of work, if you want to get some decent
feedback to the user. Adam Shelly hammered out a reasonably complete solution
though, so let's have a look at it:

  $CheerThreshold = 6 #decrease to get more random encouragement
  $LongThreshold = 120 #minimum time to be considered a "long" run
  
  class Phase
   attr_reader :action, :seconds
   def initialize action, time
     @action = action.downcase
     @seconds = time.to_i
   end
  end
  
  # ...

We can see some setup work here for variables that allow users to tweak the
output. We also have the trivial Phase class definition, which is just a data
class for linking actions and times.

Here's the main event loop:

  class Coach
   def initialize filename
     File.open(filename) {|f|
       @rawdata = f.read.split("\n")
     }
     @duration = 0
     @runs = @longs = @walks = 0
     @encouragometer = 0
     @step = [30,15,10,5,5]
   end
  
   def coach
     build_timeline
     say summarize(2)
     say start_prompt
     @time = Time.now
     @target_time = @time
     while (phase = @phases.shift)
       update_summary phase
       narrate_phase phase
       if @phases.size > 0
         say transition(@phases[0].action)
         say summarize(rand(2))
       end
     end
     say finish_line
   end
   
   # ...

There's nothing too interesting about initialize() which is just assigning
defaults to the instance variables. Have a look at the coach() method though.
This is the process the application runs through, and I really like how well it
reads. It builds up the timeline of events, hits user with a summary and
starting prompt, then launches into Phase processing. Each Phase is narrated to
the user, and then the code transitions naturally to the next Phase. Finally
the code sends the finish line message to indicate a successful workout.

Let's see what narrating a phase involves:

   # ...
   
   def narrate_phase phase
     say what_to_do_for(phase)
     @target_time += phase.seconds
     delta = (@target_time - Time.now).to_i
     stepidx = 0
     while (delta > 0)
       stepidx+=1 if delta < @step[stepidx]+1
       wait_time = delta % @step[stepidx]
       wait_time += @step[stepidx] if wait_time <= 0
       wait(wait_time)
       delta = (@target_time - Time.now).to_i
       encourage_maybe
       say whats_left(phase.action,delta) if delta > 0
     end
   end
   
   # ...

Obviously, this method is mostly about time management. It breaks a Phase down
into smaller chunks, so that it can provide encouragement frequently and inform
the user of what is left to be done.

Note the clever output messages here again that read so naturally:
what_to_do_for(), encourage_maybe(), and whats_left().

   # ...
   
   def update_summary phase
     @duration -= phase.seconds
     @runs -= 1 if phase.action == 'run'
     @longs -= 1 if phase.action == 'run' and phase.seconds >= $LongThreshold
     @walks -= 1 if phase.action == 'walk'
   end
   
   def build_timeline
     @phases = @rawdata.map {|command|
       p = Phase.new(*command.split)
       @duration += p.seconds
       @runs += 1 if p.action == 'run'
       @longs += 1 if p.action == 'run' and p.seconds >= $LongThreshold
       @walks += 1 if p.action == 'walk'
       p
     }
   end
   
   # ...

These two methods are quite similar save that one adds and the other subtracts.
First, build_timeline() constructs the Phase objects from the import file. As
it goes through, it counts things like the total number of walks and runs a
person needs to complete. Then, update_summary() runs inside each Phase of the
event loop ticking off the walks and runs the user has completed.

Here's the say() method that would eventually need to be replaced with speech
programming:

   # ...
   
   def say s
     puts s
     #todo: replace with speech
   end
   
   # ...

Now, take a look at this:

   # ...
   
   def wait n
     if $DEBUG
       puts "...waiting #{n} seconds..."
       @target_time -= n
     else
       $stdout.flush
       sleep(n)
     end
   end
   
   # ...

This is obviously the delay method and it mainly just calls sleep(). However, I
like how it can be set to just explain what the pause would have been, in $DEBUG
mode. That makes testing the application much more pleasant.

Two more helper methods:

   # ...
   
   def encourage_maybe
     @encouragometer += rand(3)
     if (@encouragometer > $CheerThreshold)
       say cheer
       @encouragometer = 0
     end
   end
   
   def timesay secs
     secs = secs.to_i
     s = ""
     if secs > 60
       min = secs/60
       secs -= min*60
       s += "#{min} minute"
       s += 's' if min > 1
       s += ' and ' if secs > 0
     end
     if secs > 0
       s += "#{secs} second"
       s += 's' if secs > 1
     end
     s
   end
   
   # ...

There's the definition for the encourage_maybe() call I pointed out earlier. It
just randomly decides if a cheer should be emitted.

The other method, timesay(), is a helper like we are use to in Rails. It just
humanizes the output of some number of seconds by breaking it into minutes and
seconds.

Next the code has several output methods, of which I'll just show a couple:

   # ...
   
   # All the phrases should be below this line, not mixed up in the logic
   def what_to_do_for phase
     s = "#{phase.action} for #{timesay(phase.seconds)} \n"
     s += "You are almost done" if @phases.size == 1
     s
   end
   def whats_left act, time
     timestr = timesay(time)
     s = [
       "You have #{timestr} more to #{act}",
       "#{act} for #{timestr} more",
       "only #{timestr} left of #{act}ing",
       "You have #{timestr} more to #{act}",
       "#{timestr} left in this phase",
       "There are #{timestr} until the next activity"
     ]
     s[rand(s.size)]
   end
   
   # ... several more output routines not shown ...
  end
  
  # ...

You can see that these methods just use simple conditional logic or random picks
to vary the program's output. With several of these methods, the end result is
a fairly good mix of prompts for the user.

Here's the last line that turns it into a solution:

  # ...
  
  Coach.new(ARGV[0]||"week3.txt").coach

My thanks to those who stole the time from their busy running schedules to code
up a solution. These scripts should have us all in shape by RubyConf!

Tomorrow, we will try an extremely common computerism, but see if we can handle
it a little better than the usual treatment...