How To Make A Calendar With Rails

Over time I have had to implement calendars on sites over and over again. I used to try to use other people's plugins, but that never really gave me the flexibility I needed, but hey it was free and fast. However, recently I needed to implement a calendar in javascript and I found all the existing libraries to be lacking, so I wrote my own.

Then I ported it to Ruby and Rails so I could use it on the server side. It turned out to not be that difficult, so I wanted to document my thought process here for later. This applies to Rails 2.2.

Start With A Resource

We'll need some kinda of Event object to display on the calendar, so let's just create the Event model and EventsController.

script/generate resource event title:string start_at:datetime description:text

If you would like an end_at or a duration then feel free to add those in as well. I'm not going to cover building the data entry forms in this article, so the fields don't really matter other than start_at.

Routes

We will need to be able to pass in what month and year we want to see, so I add an extra route for that:

map.event_year_month "/events/:year/:month", :controller => "events", :action => "index"
map.resources :events

So we can CRUD events if we like and we can also see events for a given year and month. Also of note, since we put :year and :month in the url and not in the query string, it's much easier to cache later if we need to.

EventsController

All the controller needs is an index action that figures out which year and month to display.

def index
  @year = (params[:year] || Time.now.utc.year).to_i
  @month = (params[:month] || Time.now.utc.month).to_i
end

Simple and concise. Also of note, I do (...).to_i since params[:year] will be an instance of String or NilClass and nil.to_i is not a method.

I'm not going to demonstrate how to implement the show action, you can handle that.

Event Model

The model should always be where most of the heavy lifting goes. Luckily Rails has some fancy tricks to make searching and other tedious things pretty easy.

class Event < ActiveRecord::Base
  default_scope :order => 'created_at DESC'

  named_scope :future, lambda { { :conditions => ["start_at > ?", Time.now.utc] } }
  named_scope :recent, lambda { 
    { :conditions => ["start_at >= ? AND start_at <= ?", 2.weeks.ago, Time.now] } 
  }
  named_scope :between, lambda { |b, e| 
    { :conditions => ["start_at >= ? AND start_at <= ?", b, e] } 
  }
  named_scope :near, lambda { |that| 
    { :conditions => ["start_at >= ? AND start_at <= ?", that - 2.weeks, that + 2.weeks] } 
  }

  def to_param
    "#{id}-#{title.parameterize}"
  end

  def self.all_for_day(date)
    between(date.beginning_of_day, date.end_of_day)
  end

  def happened?
    start_at < Time.now
  end

  def future?
    !happened?
  end
end

I threw in a few extra useful methods, but the primary things we are interested in are Event.between and Event.for_day. Also of note, having to_param be "#{id}-#{title.parameterize}" (example url: /events/3-some-string) is a good thing, since Event.find("3-some-string") will really just be Event.find(3), so it's as if the title wasn't even appended when you go to lookup that specific event.

Calendar Helper

In events_helper.rb we just need to add a method to generate our calendar. This is the hard part and I am going to walk through the logic of it step by step.

def calendar(month, year)
end

All we need to generate a calendar is a month and a year. Something like 1, 2010.

def calendar(month, year)
  beginning = Time.now.utc.change(:month => month, :year => year).beginning_of_month
  days_in_this_month = Time::days_in_month(month, year)
end

Next, we need to figure out what the first day of the month passed in is and how many days are in that month. Now, we know how many days are in this month and we now have a variable pointing at the first day of this month, we need to think about what we want to do now.

If you think of (or look at a) calendar you will see that they list the prior and next months before and after the current month, to make each row have a complete seven day listing. This is obvious and second nature for us to recognize, but how to we produce that from the info we currently have.

We need to make an array of six weeks, each week being an array of seven days. The first array might show some of the prior month, so we need to backtrack to Sunday (let's assume our calendar starts on Sunday for now) before we start. Later, when we run out of days for the current month, we can just keep going until the array is full to show the next month's days. This is an example for August 2009:

[ 
  [26, 27, 28, 29, 30, 31, 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, 28, 29],
  [30, 31, 1, 2, 3, 4, 5]
]

For August, beginning would be pointing at the 1 on Saturday. We need to find out how many days we need to show of the previous month. This turns out to be really easy, since Ruby returns an integer for Time#wday with Sunday being 0. And it turns out, if the first day was on Sunday, we would want to show zero additional days before it. So if Saturday is 6 and our month ends on Monday which is 1, we need to show 5 additional days (6 - 1 = 5).

def calendar(month, year)
  beginning = Time.now.utc.change(:month => month, :year => year).beginning_of_month
  days_in_this_month = Time::days_in_month(month, year)

  extra_days_at_beginning = beginning.wday
  extra_days_at_end = 6 - beginning.end_of_month.wday
end

So if we add up the extra_days_at_beginning, extra_days_at_end, and days_in_this_month we should get 42 or 35 (some months are only five weeks).

def calendar(month, year)
  beginning = Time.now.utc.change(:month => month, :year => year).beginning_of_month
  days_in_this_month = Time::days_in_month(month, year)

  extra_days_at_beginning = beginning.wday
  extra_days_at_end = 6 - beginning.end_of_month.wday

  total_days = extra_days_at_beginning + days_in_this_month + extra_days_at_end
  number_of_weeks = total_days / 7

  first_day = beginning - extra_days_at_beginning.days

  calendar_array = []

  number_of_weeks.times do |week|
  end

  calendar_array
end

Just calculating the total_days that the calendar will display (either 42 or 35) and then the number_of_weeks (either 6 or 7). We are going to use first_day as a starting point to increment from when building the actual array.

def calendar(month, year)
  beginning = Time.now.utc.change(:month => month, :year => year).beginning_of_month
  days_in_this_month = Time::days_in_month(month, year)

  extra_days_at_beginning = beginning.wday
  extra_days_at_end = 6 - beginning.end_of_month.wday

  total_days = extra_days_at_beginning + days_in_this_month + extra_days_at_end
  number_of_weeks = total_days / 7

  first_day = beginning - extra_days_at_beginning.days

  calendar_array = []

  number_of_weeks.times do |week|
    calendar_array[week] = []

    7.times do |day|
      position = day + (week * 7)
      current_day = first_day + position.days
      calendar_array[week][day] = {
        :day => current_day,
        :events => Event.all_for_day(current_day)
      }
    end
  end

  calendar_array.each { |week| yield(week) } if block_given?

  calendar_array
end

position is the current position in the 7 x 6 grid, and it's gets added to the first day (position starts at zero) to find where we are in the loop. Also, there is a nice line that ends with block_given? that makes it quicker to do loops when using this method.

View

So now we can finally use this in our view (I am using haml):

.nav
  = link_to "Previous Month", event_year_month_path(:year => @year, :month => @month-1)
  = link_to "Next Month", event_year_month_path(:year => @year, :month => @month+1)

%h1= "#{Date::MONTHNAMES[@month]} #{@year}"

%table.events
  %thead
    %tr
      - Date::DAYNAMES.each do |day_name|
        %th= day_name
  %tbody
    - calendar(@month, @year) do |week|
      %tr.week
        - week.each do |day|
          %td
            %span.day= day[:day].day

            - unless day[:events].blank?
              %ul.events
                - day[:events].each do |event|
                  %li= link_to event.title, event_path(event)

Finished

It's possible to start the calendar on a different day, always show six weeks no matter what, and all kinds of other stuff when you role your own calendar. Don't be afraid of taking an hour and working out your own solution instead of relying on code you didn't write. Or at least read over and understand the code you are using.

Yeah, this was way too long...