DEV Community

Michael Lobman
Michael Lobman

Posted on

Workout Programmer

A few weeks ago, I began a new workout plan but spent the majority of my gym time on looking at my phone, referencing which percentages belong to which sets, which sets belong to which rep-schemes, etc...

When I returned home, I began building the Workout Programmer (deployed on Heroku) so I could spend less time scrolling and more time working out.

The application uses a PostgreSQL database with six relational tables:

  • Main Exercises (MainExes)
  • Accessory Exercises (AccessoryExes)
  • Weeks
  • Exercise Sets (ExSets)
  • Progressions
  • Users

As for the relationships between the tables, an instance of Main Exercise has many Progressions, has many Users through Progressions, and has many Accessory Exercises:

class MainEx < ApplicationRecord
    has_many :progressions
    has_many :users, through: :progressions
    has_many :accessory_exes
end
Enter fullscreen mode Exit fullscreen mode

An instance of Accessory Exercise belongs to an instance of Main Exercise:

class AccessoryEx < ApplicationRecord
    belongs_to :main_ex
end
Enter fullscreen mode Exit fullscreen mode

An instance of Week has many Exercise Sets and Users:

class Week < ApplicationRecord
    has_many :ex_sets
    has_many :users
end
Enter fullscreen mode Exit fullscreen mode

An instance of Exercise Set belongs to a Week:

class ExSet < ApplicationRecord
    belongs_to :week
end
Enter fullscreen mode Exit fullscreen mode

Progressions is a join table, with each instance belonging to one User and one Main Exercise:

class Progression < ApplicationRecord
    belongs_to :user
    belongs_to :main_ex
Enter fullscreen mode Exit fullscreen mode

Finally, a User has many Progressions, has many Main Exercises through Progressions, belongs to an instance of Week, and has many Exercise Sets through Week:

class User < ApplicationRecord
    has_secure_password
    has_many :progressions
    has_many :main_exes, through: :progressions
    belongs_to :week
    has_many :ex_sets, through: :week
end
Enter fullscreen mode Exit fullscreen mode

With the relationships established, let's walk through the application's user experience.

At signup, the user will have to elect which of the four main exercises to include in their workout, as well as their current fitness-level for the exercise.

Upon submitting, the endpoint maps to the create method in the users controller:

class UsersController < ApplicationController
    skip_before_action :authorize, only: :create

    def create
        user = User.create!(user_params)
        params[:exercises].each do |exercise|
            unless exercise[:include] == false
                ex_id = MainEx.find(exercise[:id]).id
                max = max(exercise[:weight], exercise[:reps])
                user.progressions.create!(main_ex_id: ex_id, baseline_max: max, current_max: max)
            end
        end
        session[:user_id] = user.id
        render json: user, status: :created
    end

    private

    def user_params
        params.permit(:email, :password, :full_name)
    end
Enter fullscreen mode Exit fullscreen mode

First, a user is created with permitted params.

Next, for each exercise in params[:exercises], a progression associated with the new user will be created unless the user elected to not include the exercise on the front end.

Inside the ex_id variable, the corresponding instance of Main Exercise is stored.

As the user is asked to enter their highest weight and most reps performed for each exercise, a "max" is created using a method inherited from Application Controller:

class ApplicationController < ActionController::API
    include ActionController::Cookies

    private

    def max (weight, reps)
        nearest_five(weight * reps * 0.0333 + weight)
    end

end
Enter fullscreen mode Exit fullscreen mode

The return value of that method is store in the max variable, which is used to create a progression for the user and this instance of main exercise.

Serializers organize the associated data so that it can be rendered to maximum effect on the front end.

Once their account is created, the user is automatically logged in and can begin exercising, with all of the week's sets and respective weight laid out before them in a clean interface built with React-Bootstrap.

Navigating Workout gif
Completing Sets gif
Changing Weeks gif
User profile

Of course, a painless user experience involves some heavy-lifting under the hood.

One problem I encountered is, while the weights given to the user are all rounded to the nearest five (as the majority of weights are in reality at the gym), the user ends up doing a lot of calculation in their head to load up the bar properly.

Fine for some. Not for others.

Enter several custom methods in the progression serializer.

First, determine_plates:

class ProgressionSerializer < ActiveModel::Serializer

  @@plates = [45,35,25,10,5, 2.5]

  private

  def determine_plates(weight, plates, plate_hash = {})
    return nil if weight <= 45

    side = (weight.to_f - 45) / 2

    if side % plates.first == 0 
        plate_hash[plates.first] = side / plates.first
        return plate_hash
    elsif side > plates.first
        num = (side / plates.first).to_i
        plate_hash[plates.first] = num 
        weight = weight - plates.first * num * 2
    end

    determine_plates(weight, plates[1..-1], plate_hash)

  end

Enter fullscreen mode Exit fullscreen mode

The method has three arguments:

  • weight
  • plates (an array stored in a class variable)
  • plate_hash (which defaults as an empty hash)

First, the method handles an edge case. If the weight argument is less than or equal to 45 (all weights are in pounds), the method returns nil. Simply, the standard bar at a gym is 45 pounds. If the weight is less than or equal to the bar, no plates will be needed.

As a bar has to have an equal number of plates on each side, it stores half of the weight argument in the variable "side".

If the side mod first plate in the plates array exactly equals 0, the if block executes. It divides the side by the first plate to determine the number of plates needed for the side. This value is stored in the plate_hash with a key of the first plate. The plate_hash is returned and the method terminates.

If the elsif conditional is true (side is greater than the first plate), that block of code fires. The side divided by the whole number of plates is stored in a variable 'num'. This value is stored in the plate_hash with a key of the first plate.

To determine how much weight still needs to be added to the bar after these plates, the overall weight of the plates is subtracted from the weight, completing the block of code and exiting the conditional statement.

Finally, the recursive call fires with the updated weight, the plates array beginning with the second element (in position '1'), and the plate_hash that has already been initialized.

Let's walkthrough the process with a weight of 200 pounds.

The first call:

def determine_plates(weight, plates, plate_hash = {})
    # weight = 205
    # plates = [45,35,25,10,5, 2.5]
    # plates_hash = {}

    return nil if weight <= 45

    # 200 <= 45 -false 
    # return not executed

    side = (weight.to_f - 45) / 2

    # subtract the weight of the bar, then divide by 2
    # side = 80

    if side % plates.first == 0 

        # 80 % 45 == 0 -false
        # 'if' block does not execute

        plate_hash[plates.first] = side / plates.first
        return plate_hash

    elsif side > plates.first

    # 80 > 45 -true
    # 'elsif' block fires

        num = (side / plates.first).to_i

        # num = (80 / 45).to_i
        # num = 1

        plate_hash[plates.first] = num 

        # plate_hash[45] = 1
        # plate_hash = { 45: 1 }

        weight = weight - plates.first * num * 2

        # weight = 205 - 45 * 1 * 2
        # weight = 115

    end

    determine_plates(weight, plates[1..-1], plate_hash)

    # determine_plates(115, [35,25,10,5,2.5], { 45: 1 })

end
Enter fullscreen mode Exit fullscreen mode

The second call:

def determine_plates(weight, plates, plate_hash = {})
    # weight = 115
    # plates = [35,25,10,5, 2.5]
    # plates_hash = { 45: 1 }

    return nil if weight <= 45

    # 115 <= 45 -false 
    # return not executed

    side = (weight.to_f - 45) / 2

    # side = 35

    if side % plates.first == 0 

    # 35 % 35 == 0 -true
    # block executes

        plate_hash[plates.first] = side / plates.first

        # plates_hash[35] = 35 / 35
        # plates_hash[35] = 1
        # plate_hash = { 45: 1, 35: 1 }

        return plate_hash

        # method terminates and returns plate_hash { 45: 1, 35: 1 }

        # 'elsif' conditional never checked


    elsif side > plates.first
        num = (side / plates.first).to_i
        plate_hash[plates.first] = num 
        weight = weight - plates.first * num * 2
    end

    determine_plates(weight, plates[1..-1], plate_hash)    
  end

Enter fullscreen mode Exit fullscreen mode

The second call reaches the return statement in the 'if' block and terminates the recursive process.

The determine_plates method is utilized by another instance method in the progressions serializer, weight_plates:

class ProgressionSerializer < ActiveModel::Serializer
    @@plates = [45,35,25,10,5, 2.5]

    def weights_plates
        base = self.object.w_max
        arr = []
        self.object.user.ex_sets.each do |set|
            weight = nearest_five(set.percentage * base)
            weight = 45 unless weight > 45
            arr << { weight: weight, plates: determine_plates(weight, @@plates) }
        end

        arr

    end

end
Enter fullscreen mode Exit fullscreen mode

First, it stores the value of self.object.w_max in the 'base' variable and initializes an empty array, storing it in the 'arr' variable.

Next, for each of the user's exercise sets (recall a User has many Exercise Sets through the Week to which it belongs), a few actions will be taken.

The set's percentage times the value stored in the 'base' variable (rounded to the nearest five thanks to a private method) is saved in the 'weight' variable. However, 'weight' will be reassigned a value of 45 if the current value of 'weight' is less than 45 (the weight of the bar, in pounds).

Finally, a hash with two key/value pairs is being shoveled into the array stored in the 'arr' variable. The key of 'weight' points to the value stored in the conveniently named 'weight' variable, and the the key of 'plates' will point to the hash returned by the previously detailed determine_plates method.

Of course, these methods are just the tip of the iceberg. Explore the application yourself to gain a sense of all of the other processes going on under the hood to create a seamless user experience.

Thank you for reading, and I hope the Workout Programmer helps you achieve your fitness goals, whatever they may be.

In the meantime, happy coding.

Oldest comments (0)