## # 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 = %() cal << %() if options[:previous_month_text] or options[:next_month_text] cal << %() colspan=3 else colspan=7 end cal << %() cal << %() if options[:next_month_text] cal << %() day_names.each do |d| unless d[options[:abbrev]].eql? d cal << "" else cal << "" end end cal << "" beginning_of_week(first, first_weekday).upto(first - 1) do |d| cal << %() else cal << %(">#{d.day}) 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 << "" if cur.wday == last_weekday content = calendar_row(event_strips, event_width, event_height, start_row, last_row..cur, &block) cal << "#{event_row(content, event_height, event_margin)}" last_row = cur + 1 end end (last + 1).upto(beginning_of_week(last + 7, first_weekday) - 1) do |d| cal << %() else cal << %(">#{d.day}) 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 << "#{event_row(content, event_height, event_margin)}
#{options[:previous_month_text]}#{options[:month_name_text]}#{options[:next_month_text]}
#{d[options[:abbrev]]}#{d[options[:abbrev]]}
#{d.day}#{cell_text}
#{d.day}
" 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) "
#{content.join}
" 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| "
#{h(event.title)}
" 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