[matt.mc]

Matt.tech_ramblings

Part 1: Real-Time Voting With PubNub

Delivering real-time updates to users has never been easier thanks to some open-source projects and great startups that have popped up. I started using PubNub for TuneWolf, and so far I couldn’t be happier.

When I first started using PubNub, they didn’t have the enormous client-library you see now and the Ruby examples were a little scarce, so I decided to build something fun in order to get acquainted with the service. I’d also been hearing great things about Padrino, so I decided to try out the two together.

The result was Song-Votr, a simple voting application that puts one round of songs up at a time. Users can vote on the songs they like and also see everyone else’s votes happening in real-time. Before we look at any code, let’s lay out the broad overview of how everything interacts.

Song-Votr serves up the current round of songs. When a user clicks on a song, the client places the vote via an AJAX post request. Song-Votr increments the selected song’s vote count along with the total number of votes for the round, then publishes a message to our PubNub channel with the new stats for the round. The user that placed the vote (along with all other connected users) receives that updated round information and the client updates the view.

image

In a nutshell, all clients are only subscribed to PubNub, and the server only publishes messages. This allows Song-Votr to be the gatekeeper of the PubNub channel, making sure that users are only getting votes that were successfully processed.

Setting Up

Since we’re using Padrino let’s go ahead and generate the project. I’m a sucker for Haml and Sass, so we’ll use those along with ActiveRecord for our ORM and, of course, jQuery. The -b flag simply runs bundle install after generation.

$ padrino g project votr -e haml -c sass -s jquery -d activerecord -b

I used ActiveRecord because I come from Rails and I’m familiar with it, but you could always switch that out for whatever adapter you prefer. Assuming you are using AR, the next step is to generate our models. Our model is dead simple, consisting of just two models: a round that has many songs.

$ padrino g model round start_time:datetime end_time:datetime total_votes:integer -a app
$ padrino g model song title:string artist:string votes:integer percentage:float round_id:integer -a app

This will also generate the necessary migrations. Before you run them, let’s go in and add a default value of 0 to total_votes in round and votes in songs. When you’re done, those migrations should look similar to what’s below.

001_create_rounds.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CreateRounds < ActiveRecord::Migration
  def self.up
    create_table :rounds do |t|
      t.datetime :start_time
      t.datetime :end_time
      t.integer :total_votes, :default => 0
      t.timestamps
    end
  end

  def self.down
    drop_table :rounds
  end
end
002_create_songs.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CreateSongs < ActiveRecord::Migration
  def self.up
    create_table :songs do |t|
      t.string :title
      t.string :artist
      t.integer :votes, :default => 0
      t.float :percentage
      t.integer :round_id
      t.timestamps
    end
  end

  def self.down
    drop_table :songs
  end
end

Once you’re done, make sure you run the migrations with the command padrino rake ar:migrate.

With our database now ready, we need to go into the models and define the relationships. While we’re in there, let’s go ahead and write a scope to get the current round since that’s something we’ll definitely need soon.

round.rb
1
2
3
4
5
6
7
class Round < ActiveRecord::Base
  has_many :songs

  scope :current, lambda {
    where("start_time <= ? and end_time > ?", DateTime.now, DateTime.now)
  }
end
song.rb
1
2
3
class Song < ActiveRecord::Base
  belongs_to :round
end

A neat feature about Padrino is its drop-in admin interface, but let’s not worry about that yet. For now, we’ll insert a round and some songs into the database from the console.

$ padrino c
> round = Round.new
> round.start_time = DateTime.now
> round.end_time = DateTime.now + 3.days
> round.save!

> song = round.songs.new
> song.title = "My Heart Will Go On"
> song.artist = "Celine Dion"
> song.save!

Repeat the song process for a few more songs so you can have things to vote on for the next 3 days. Once you’re done, your database should be prepped and ready!

Front page and layout

Because there are only two routes, we can get away with just using the base app.rb file as our only controller. Let’s go ahead and make our root route and decide what we need to pass to our view. We’ll want a timer to show how much time is left in the round, along with the round itself (and associated songs). Notice we’re using the current scope we set up in our model. Padrino is based on Sinatra, so if you’ve ever used Sinatra this syntax should be very familiar.

app.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
get '/' do

  @round = Round.current.first

  unless @round.blank? || @round.end_time.past?

    difference = @round.end_time - DateTime.now.to_i
    @timer = difference
    @songs = @round.songs.order("artist")
    @msg = { :songs => @songs, :round => @round, :total_votes => @round.total_votes }.to_json

  end

  render 'index'
end

Now it’s time to actually make an interface for people to see. I was planning on using this for a demo, so I took the time to style it up. If you’d like to go ahead and simply copy my stylesheet from the repository, go ahead and do that now.

Our layout is going to be simple for now. I almost always start my projects with Nicolas Gallagher’s Normalize, and Keith Wood’s jquery.countdown is what’s going to make our clock actually count down.

views/layouts/application.haml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
!!! 5
%html
  %head
    %title Votr
    %meta{ :content => "", :name => "description" }
    %meta{ :content => "", :name => "author" }
    %meta{ :content => "3 days", :name => "revisit-after" }
    %link{ :href => "http://creativecommons.org/licenses/by/3.0/", :rel => "license", :title => "Creative Commons Attribution 3.0 Unported License" }
    %script{ :src => "https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js" }
    = stylesheet_link_tag 'normalize', 'application'
    = javascript_include_tag 'application', 'jquery.countdown'
  %body
    = yield

    %footer
      %p
        #{link_to("SongVotr", "http://github.com/sh1ps/padrino-pubnub-votr")}. By #{link_to("@matt_mcclure", "http://www.twitter.com/matt_mcclure")}.

  - unless @round.blank?
    :javascript
      $(function(){
            $('#clock').countdown({until: #{@timer.to_i}, format: 'HMS', compact: true, onExpiry: disableVoting});
          });

The index view itself is pretty self explanatory. We want to display the clock at the top, followed by a list of the songs in the current round, their current votes, and the percentage of the total vote they’ve received. As you can see, we went ahead and made each song title a link. We’ll be making that /vote route next.

views/index.haml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- unless @timer.blank?

  %header
    %h1 What do <span class="blue">you</span> want to hear next?
    %p Click the song titles to vote.
    %h1#clock.blue

  - unless @songs.blank? 
    %ul.selections
      - @songs.each do |s|
        %li[s]
          %h3
            = link_to "#{s.artist} - #{s.title}", "/vote?id=#{s.id}", :class => "vote"
            %small (<span class="votes">#{s.votes}</span> votes)
          .progress
            .outline
            .bar
  - else
    %p No songs to vote on!

- else

  %header
    %h1 Nothing to currently vote on. Come back later.

Placing Votes

The /vote route is where almost all of the real functionality of the app lies. Because we’re only allowing for one active round at a time, we can just get the current round. We can cut down on the number of SQL queries by going ahead and eager loading songs along with the round. This can be done via the includes method you see on Round in the second line.

Other than that, all that’s happening is incrementing the total votes for the round and the number of votes for the song. We then recalculate that song’s percentage (just for archival purposes) and return success or failure based on whether or not the song / round was persisted.

Notice that we’re querying the database again for all of the songs. This is because when we plug PubNub in, we need to be publishing all of the songs in order to be able to recalculate all of their percentages and votes on the client side.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
post '/vote', :provides => :json do
  @round = Round.includes(:songs).current.first

  unless @round.end_time.past?
    @songs = @round.songs.order("artist")
    @song = @songs.find(params[:id])
    @song.votes = @song.votes + 1
    @round.total_votes = @round.total_votes + 1

    @song.percentage = @song.votes.to_f / @round.total_votes.to_f

    if @song.save and @round.save
      @songs = Round.current.first.songs
      logger.info "Total votes: #{@round.total_votes}"

      render :success => true, :attributes => { 'round' => @round, 'songs' => @songs }

    else
      render :success => false, :attributes => {:message => "Unable to save your vote..."}
    end

  else
    render :success => false, :attributes => {:message => "The voting is already over."}
  end
end

Now that the /vote route works, we need to add a little Javascript so the vote is sent via ajax. Change the javascript block in the layout to look something like this:

views/layouts/application.haml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
:javascript
  // Reload the window whenever the clock gets to zero so we can get the next round.
  function disableVoting() {
    alert("This round's over! New round starting...");
    window.location.reload();
  }

  $(function(){
    $('#clock').countdown({until: #{@timer.to_i}, format: 'HMS', compact: true, onExpiry: disableVoting});
      
    $('.vote').click(function(event) {
    event.preventDefault();
    var $link = $(this);
  
      $.ajax({
        type: 'POST',
        url: $link.attr('href'),
        success: function (response) {
          console.log("Vote placed.");
        },
        error: function (response) {
          console.log("Error: vote not placed.");
          alert("Vote could not be placed at this time.");
        }
      });
    });
  });

Making it Live

Now that we have our rounds and we’re able to post votes to them, we need to make it so everyone can see everyone’s votes as their happening. Because this post is starting to get incredibly lengthy, let’s take a breather and tackle the PubNub integration in the next post.

If you’re interested…

If this interested you, Twilio posted a tutorial on building a similar project using their service along with Node.js and CouchDB. Happy hacking!

- Matt

Comments