Rails Project: CoffeeCups 2.0

Posted by Natasha Chernis on September 16, 2019

For my first Rails project, I ported the same domain I used for my Sinatra project – a CMS that, upon user signup and login, either conventional or via GitHub OAuth, allows a user to:

  • Create, rate, track, edit, and delete cups of coffee they consume
  • View what other users are sipping
  • Receive recommendations for new coffees to try

While overhauling my CoffeeCups app with Rails was much easier said than done, from the end user viewpoint, the main noticeable difference is the new rating/recommendation feature. I built this feature to be as simple as possible with potential to incorporate more sophisticated algorithms and scaling in the foreseeable future.

Let’s dive into the process of building a rating & recommendation feature:

Step 1: First, I wrote in plain English what needed to be done.

  1. Select coffees you rated 4 or 5.
  2. Identify other users who also rated the same coffees a 4 or 5.
  3. Identify other coffees the user(s) rated a 4 or 5 that you have not yet sipped or rated.
  4. Present recommendations.

Step 2: Next, I checked to make sure all of my model associations were wired up correctly to make available the Active Record methods I would need.

class User < ApplicationRecord
    has_many :cups
    has_many :coffees, through: :cups
    has_many :ratings, through: :cups
end
	
class Cup < ApplicationRecord
    belongs_to :user
    belongs_to :coffee
    belongs_to :rating
end
	
class Coffee < ApplicationRecord
    has_many :cups
    has_many :users, through: :cups
    has_many :ratings, through: :cups
end

class Rating < ApplicationRecord
    belongs_to :user
    has_one :cup
    has_one :coffee, through: :cup
end

Step 3: Write the logic in app/models/user.rb

def favorite_coffees
    self.ratings.joins(:user).where(rating: [4,5]).map {|rating| rating.coffee}
end

This method, when called on a user, will first return (for method chaining) a collection of ratings with a value of 4 or 5 a user has generated by rating a coffee (through a cup), which will than be iterated over to return a collection of all the coffee instances those ratings are associated with. In essence, a user’s “favorite coffees” will be returned, thereby answering step 1 from above.

def self.compare_favorite_coffees(user) # returns users with similar taste in coffee to the current user
    self.select {
        |u| user.favorite_coffees.any? {
            |coffee| u.favorite_coffees.include?(coffee)
        }
    }.reject {|u| u == user}
end

Given a user, this User Class method will return any other users that share any favorite coffees.

def coffee_recommendations(similar_users) # returns a collection of coffees recommended for the user to try
     similar_users.collect {|similar_user| similar_user.favorite_coffees
     }.first
end

def remove_coffees_already_tried(coffees)
     coffees.reject {|coffee| self.coffees.include?(coffee)} if coffees
end

In the above methods, the first takes in the return value of the previous class method (a collection of other users with similar taste) as an argument and iterates over the collection to return their favorites coffees. The resulting collection is passed to the second method to be voided of any coffees the user of interest has already tried, shown below.

def make_recommendations(current_user) # runner method for users#show
  if self == current_user && !self.coffees.empty?
     remove_coffees_already_tried(self.coffee_recommendations(User.compare_favorite_coffees(self)))
  end
end

Step 4: Execute the logic in the controller and present the resulting coffee recommendations for the user to see.

I designed coffee recommendations to display on the user’s show page, or the view that presents a feed of all of the cups a user has consumed. Since it is possible for a user to be either viewing their own show page, or Coffee Cups Profile, if you will, or someone else’s Coffee Cups Profile, a user should only see their recommendations if they are viewing their own profile page.

In the users#show action, the above method is called on the user whose profile is being viewed (this info is available from setting @user = User.find_by(id: params[:id])), and the code to generate coffee recommendations is thus only executed if current_user, which is passed in as an argument, is indeed visiting their own “profile”.

Additionally, the program will not attempt to find recommendations if a user has not yet sipped any cups of coffee, given that it would be tough to make comparisons or assumptions without any relevant data.

The resulting recommendations will be passed to the corresponding view as an instance variable and displayed for the user to see on their profile. app/views/users/show.html.erb

<% if @recs && !@recs.empty? %>
    <h2>Coffees we recommend</h2>
    <table>
    <%= render collection: @recs, partial: 'coffees/coffee_tr', as: :coffee %>
    </table>
<% end %>

A partial I previously created for generating an HTML table to display on the Coffee Index page came in handy.

My logic of declaring users similar if they possess ANY common favorites would not be good in production, because if lots of users are sipping lots of cups and rating lots of coffees, it doesn’t make sense for a user you share one favorite coffee with to be considered equally as similar to you as a user with whom you share five favorite coffees. Recommendations of the latter user’s other favorite coffees should be prioritized higher than the former.

Thus my next step with my ongoing, evolving project will be to implement logic in which other users are scored based on how many coffees were rated similarly, so that coffee recommendations derived from high scoring users can be prioritized . . . and also to add some JavaScript flare ;) .