## | |
# 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