solotimes
7/24/2011 - 1:06 PM

http://www.cuppadev.co.uk/webdev/making-a-real-calendar-in-rails/

## 
# Calendar helper with proper events
# http://www.cuppadev.co.uk/webdev/making-a-real-calendar-in-rails/
#
# (C) 2009 James S Urquhart (jamesu at gmail dot com)
# Derived from calendar_helper
# (C) Jeremy Voorhis, Geoffrey Grosenbach, Jarkko Laine, Tom Armitage, Bryan Larsen
# Licensed under MIT. http://www.opensource.org/licenses/mit-license.php
##

# Ever wanted a calendar_helper with proper listed events, like all-day events in ical or google calendar?
# Well here is how you do it!

# Firstly, lets start off with the modified calendar_helper helpers.

  def calendar(options = {}, &block)
    block ||= Proc.new {|d| nil}

    defaults = {
      :year                => Time.now.year,
      :month               => Time.now.month,
      :table_class         => 'calendar',
      :month_name_class    => 'monthName',
      :other_month_class   => 'otherMonth',
      :day_name_class      => 'dayName',
      :day_class           => 'day',
      :abbrev              => (0..2),
      :first_day_of_week   => 0,
      :accessible          => false,
      :show_today          => true,
      :previous_month_text => nil,
      :next_month_text     => nil,
      :start               => nil,
      :event_strips        => nil,  # [[nil]*days, ...]
      :event_width         => 81,   # total width per day (including margins)
      :event_height        => 24,   # height
      :event_margin        => 2     # height margin
    }
    options = defaults.merge options
    
    options[:month_name_text] ||= Date::MONTHNAMES[options[:month]]

    first = Date.civil(options[:year], options[:month], 1)
    last = Date.civil(options[:year], options[:month], -1)
    
    start = options[:start]
    event_strips = options[:event_strips]
    event_width = options[:event_width]
    event_height = options[:event_height]
    event_margin = options[:event_margin]

    first_weekday = first_day_of_week(options[:first_day_of_week])
    last_weekday = last_day_of_week(options[:first_day_of_week])
    
    day_names = Date::DAYNAMES.dup
    first_weekday.times do
      day_names.push(day_names.shift)
    end

    # TODO Use some kind of builder instead of straight HTML
    cal = %(<table class="#{options[:table_class]}" border="0" cellspacing="0" cellpadding="0">)
    cal << %(<thead><tr>)
    if options[:previous_month_text] or options[:next_month_text]
      cal << %(<th colspan="2">#{options[:previous_month_text]}</th>)
      colspan=3
    else
      colspan=7
    end
    cal << %(<th colspan="#{colspan}" class="#{options[:month_name_class]}">#{options[:month_name_text]}</th>)
    cal << %(<th colspan="2">#{options[:next_month_text]}</th>) if options[:next_month_text]
    cal << %(</tr><tr class="#{options[:day_name_class]}">)
    day_names.each do |d|
      unless d[options[:abbrev]].eql? d
        cal << "<th scope='col'><abbr title='#{d}'>#{d[options[:abbrev]]}</abbr></th>"
      else
        cal << "<th scope='col'>#{d[options[:abbrev]]}</th>"
      end
    end
    cal << "</tr></thead><tbody><tr>"
    beginning_of_week(first, first_weekday).upto(first - 1) do |d|
      cal << %(<td class="#{options[:other_month_class]})
      cal << " weekendDay" if weekend?(d)
      if options[:accessible]
        cal << %(">#{d.day}<span class="hidden"> #{Date::MONTHNAMES[d.month]}</span></td>)
      else
        cal << %(">#{d.day}</td>)
      end
    end unless first.wday == first_weekday
    
    start_row = beginning_of_week(first, first_weekday)
    last_row = start_row
    first.upto(last) do |cur|
      cell_text, cell_attrs = nil#block.call(cur)
      cell_text  ||= cur.mday
      cell_attrs ||= {:class => options[:day_class]}
      cell_attrs[:class] += " weekendDay" if [0, 6].include?(cur.wday) 
      cell_attrs[:class] += " today" if (cur == Date.today) and options[:show_today]  
      cell_attrs = cell_attrs.map {|k, v| %(#{k}="#{v}") }.join(" ")
      cal << "<td #{cell_attrs}>#{cell_text}</td>"
      
      if cur.wday == last_weekday 
        content = calendar_row(event_strips,
                               event_width,
                               event_height,
                               start_row,
                               last_row..cur,
                               &block)
        cal << "</tr>#{event_row(content, event_height, event_margin)}<tr>"
        last_row = cur + 1
      end
    end
    (last + 1).upto(beginning_of_week(last + 7, first_weekday) - 1)  do |d|
      cal << %(<td class="#{options[:other_month_class]})
      cal << " weekendDay" if weekend?(d)
      if options[:accessible]
        cal << %(">#{d.day}<span class='hidden'> #{Date::MONTHNAMES[d.mon]}</span></td>)
      else
        cal << %(">#{d.day}</td>)        
      end
    end unless last.wday == last_weekday
    
    content = calendar_row(event_strips,
                           event_width,
                           event_height, 
                           start_row,
                           last_row..(beginning_of_week(last + 7, first_weekday) - 1),
                           &block)
    cal << "</tr>#{event_row(content, event_height, event_margin)}</tbody></table>"
  end

  def calendar_row(event_strips, event_width, event_height, start, date_range, &block)
    start_date = date_range.first
    range = ((date_range.first - start).to_i)...((date_range.last - start + 1).to_i)
    idx = -1
  
    last_offs = 0
    event_strips.collect do |strip|
      idx += 1
      range.collect do |r|
        event = strip[r]
        
        if !event.nil?
          # Clip event dates (if it extends before or beyond the row)
          dates = event.clip_range(start_date, date_range.last)
          if dates[0] - start_date == r-range.first
            # Event somewhere on this row
            cur_offs = (event_width*(r-range.first))
            start_d = event.start_date.to_date
            end_d = event.end_date.nil? ? start_d+1 : event.end_date.to_date+1
            block.call(event, dates[1]-dates[0], cur_offs, idx)
          else
            nil
          end
        else
          nil
        end
      end.compact
    end
  end
  
  def event_row(content, height, margin)
    "<tr><td colspan=\"7\"><div class=\"events\" style=\"height:#{(height+margin)*content.length}px\">#{content.join}</div><div class=\"clear\"></div></td></tr>"
  end

##
## What is the difference?
##

# Instead of yielding for each day column, we yield for displaying each event displayed in the 
# supplied event_strip.
# Instead of getting clumped in a single column, events are placed in rows after each set of day cells, 
# so they can be spread over multiple days.

##
## Events?
##

# Events are merely ActiveRecord objects with the following schema:

    create_table :events do |t|
      t.integer  "calendar_id"
      
      t.string :title
      t.datetime :start_date
      t.datetime :end_date, :default => nil
      t.text :description
    end

# They also have two crucial helper functions:

  def to_date
    (end_date || start_date).to_date + 1
  end
  
  def clip_range(start_d, end_d)
    # Clip start date
    if (start_date < start_d and to_date > start_d) 
      clipped_start = start_d
    else
      clipped_start = start_date.to_date
    end
    
    # Clip end date
    if (to_date >= end_d)
      clipped_end = end_d + 1
    else
      clipped_end = to_date
    end
    
    [clipped_start, clipped_end]
  end

##
## Event strip?
##

# An event strip is a list of arrays containing events corresponding to what goes on in a particular day, 
# encompassing the whole period displayed in the calendar.
# An example is as follows:

# [
# [ Event(0), nil     ,Event(1), Event(1), Event(2), nil,      nil, ... ]
# [ Event(3), Event(3),Event(3), Event(3), Event(3), Event(3), Event(3), ... ]
# ]

# So we can see, the event strip closely resembles what should be displayed on the calendar, 
# with each array representing a separate "row" in which the events should be placed.

# Events 0 through 2 dont conflict with one another, so they can exist on the same row.
# Event 3 however exists for a whole 7 days and thus conflicts with events 0 through 2, 
# so it gets placed on its own row.

##
## Ok, so how do we generate these event strips?
##

# The algorithm is simple:
#   1) Start off with the initial blank event strip encompassing all the dates represented 
#      in the calendar ends.
#   2) For each event:
#     3) Find out the range of dates it encompasses in the strip
#     4) For each existing strip
#       5) If the range is free, set it and go to the next event
#       6) Else, go to the next strip
#     7) If the event didn't fit in the existing strips, make a new strip
#       8) Fit the event in the new strip and go to the next event

# Thus in the controller you will need something like the following, 
# which grabs all the events for the calendar and inserts them into the event strips 
# according to the algorithm.

    @month = params[:month].nil? ? Time.now.month : params[:month].to_i
    @year = params[:year].nil? ? Time.now.year : params[:year].to_i
    
    # Start of month, end of month
    @now_date = Date.civil(@year, @month)
    @prev_date = @now_date - 1.month
    @prev_date = Date.civil(@prev_date.year, @prev_date.month, -1)
    @next_date = @now_date + 1.month
    @next_date = Date.civil(@next_date.year, @next_date.month)
    
    @first_day_of_week = 1
    
    # offset by weekdays
    @strip_start = beginning_of_week(@now_date, @first_day_of_week)
    @next_date = beginning_of_week(@next_date + 7, @first_day_of_week)-1
    
    # initial event strip
    @event_strips = [[nil] * (@next_date - @strip_start + 1)]
    
    @events = Event.find(:all,
               :include => :calendar,
               :conditions => ['((start_date >= ? AND start_date < ?) OR 
                                (end_date NOT NULL AND
                                  (end_date > ? AND start_date < ?) 
                                ))',
                @strip_start, @next_date, 
                @strip_start, @next_date+1], :order => 'start_date ASC').collect do |evt|
      cur_date = evt.start_date.to_date
      end_date = evt.to_date
      cur_date, end_date = evt.clip_range(@strip_start, @next_date)
      
      range = ((cur_date - @strip_start).to_i)...((end_date - @strip_start).to_i)
      
      # Find strip
      found_strip = nil
      for strip in @event_strips
        is_in = true
        
        # Are all the spaces free?
        range.each do |r| 
          if !strip[r].nil? 
            is_in = false
            break
          end
        end
        
        # Found it yet?
        if is_in
          found_strip = strip
          break
        end
      end
      
      # Make strip or add to found strip
      if !found_strip.nil?
        range.each {|r| found_strip[r] = evt}
      else
        found_strip = [nil] * (@next_date - @strip_start + 1)
        range.each {|r| found_strip[r] = evt}
        @event_strips << found_strip
      end
      
      evt
    end

# (Note that i had to borrow the beginning_of_week function from calendar_helper to get the same dates)

##
## I've got events, but how do i display them?
##

# Somewhere in your view, you should have:

  calendar events_calendar_opts do |event, days, cur_offs, idx|
    "<div class=\"event\" style=\"background: #{event.color}; width: #{(81*days)-1}px; top: #{idx*18}px; left:#{cur_offs}px; \"><div>#{h(event.title)}</div></div>"
  end

# As for styling, ensure the following:
#   - Your events need to be absolutely positioned within the events block
#   - Width of the day column (in my case, 81) should match the event width specified for the helper.
#   - For columns, don't use border. Instead make a repeating background image

# i.e. somethng like this...

"
content { width: 600px; }

table.calendar { 
  background-image: url('../images/cal.png');
  background-repeat: repeat-y;
}

.day { width: 100px; }
.events { position:relative; border-bottom: 1px solid #d5d5d5; }

.event {
  overflow:hidden;
  font-size: 12px;
  text-align: left;
  position:absolute;
  height: 16px;
}

.event div { cursor: pointer; padding-left: 6px; color:#ffffff; text-decoration: none; }
"

##
## To conclude
##

# Any suggestions or improvements? Feel free to fork this gist.

# - JamesU