Skip to content

Instantly share code, notes, and snippets.

@jamesu
Created January 28, 2009 18:42
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 16 You must be signed in to fork a gist
  • Save jamesu/54116 to your computer and use it in GitHub Desktop.
Save jamesu/54116 to your computer and use it in GitHub Desktop.
##
# 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment