Skip to content

Instantly share code, notes, and snippets.

@jamesu
Created January 28, 2009 18:42
Show Gist options
  • Save jamesu/54116 to your computer and use it in GitHub Desktop.
Save jamesu/54116 to your computer and use it in GitHub Desktop.

Revisions

  1. jamesu created this gist Jan 28, 2009.
    365 changes: 365 additions & 0 deletions Making a Real Calendar in Rails.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,365 @@
    ##
    # 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