CalendarHelper Meet Builder::XmlMarkup

Posted by Trey Wed, 06 Aug 2008 12:20:00 GMT

Your choices for calendar builders for rails aren't many, but the CalendarHelper plugin originally by Jeremy Voorhis has worked well for me—that is until I needed to drastically change the HTML it outputs. Immediately after digging in to the code I found the following comment: "TODO Use some kind of builder instead of straight HTML." Read on for my conversion of CalendarHelper to use Builder::XmlMarkup.


CalendarHelper

It's usage is simple. It mixes in to ActionView::Base, adding a method "calendar" which takes a hash of options and yields to a block whose return value is rendered as each date cell. In addition to the standard concerns for a calendar, it allows for configurable links in the header, css classes on various elements, day of the week abbreviations, and most importantly day of the week ordering. The last feature causes a nice twist in the code, but we'll deal with that in a moment. First let's look at the code as it is originally.

This gives you a feel for how the HTML is generated.

 # 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]}">#{Date::MONTHNAMES[options[:month]]}</th>)
    cal << %(<th colspan="2">#{options[:next_month_text]}</th>) if options[:next_month_text]
    cal << %(</tr><tr class="#{options[:day_name_class]}">)

This is a fairly common pattern: accumulate the output in a variable and return the result at the end. It works fine for small code blocks. Many of my helper methods use this same pattern. However, when the output grows in complexity the code can be difficult to maintain. It requires extra effort to make the HTML output readable within the code—here docs do help. And even then the outputted HTML isn't always pretty. Enter Builder::XmlMarkup. It exists to make XML generation simple and allow the structure of the code to mirror the structure of the outputted data.


Builder::XmlMarkup

Builder::XmlMarkup takes advantage of ruby's immensely useful method_missing. method_missing serves as a catch all for any instance method that is not defined in a class. Through it's magic ruby allows for things like:

    xhtml = []
    cal = Builder::XmlMarkup.new(:target => xhtml, :indent => 2)
    cal.table(:class => options[:table_class], :border => 0, :cellspacing => 0, :cellpadding => 0) do
      cal.thead do
        cal.tr do

Any method called on the builder object is converted to a tag name. The method can be called with or without a block. When invoked with a block any calls to the builder contained in the block are nested within the block's tag. When invoked without a block the first argument is treated as the contents of the block. Either one can be invoked with a hash representing the attributes for the tag.

Converting the existing code to use the builder was a straight forward process of going through the code and replacing it with the corresponding builder. Unfortunately, the existing code's output statements did not logically follow the code nesting. In some places tags were started within loops and terminated at a different level. Rather than add conditionals to the logic so that it followed the structure of the document it was outputting, I took an opportunity to refactor the algorithm it used, which presents us with the twist I mentioned earlier.


Algorithm-smalgorithm

A month does not always start on the first day nor does it end on the last day of a week. The calendar then can be thought of as containing three distinct sections: days from the previous month, days within the month, and days from the next month.

This is how the existing code treats a month, as three distinct sections. Each section's loop may or may not terminate the row tr tag, which is where the nesting problem from above occurs.

To fit the problem in to the confines of our builder—each week's row tr tag must be a single block--we have to rethink the algorithm. A month calendar can be thought of as a two dimensional array. The first dimension representing the weeks of the month, and the second representing the days of a week. This fits nicely with the requirements of our builder, but we only know the first and last day of the month which do not necessarily start on the first and end of the last day of the week. What we need to find given the first and last day of the month, is the date of the first and last day of the week.

Finding this is easy enough. The Date object gives us the day of the week in the wday method. Counting is done with Sunday equal to zero. So we take the first day of the month's week day and subtract that many days to find the date of the start of the week.

    first = Date.civil(options[:year], options[:month], 1)
    first = first - first.wday

After using the corresponding algorithm to find the last day of the last week of the month we can then iterate from the new first day to the new last day of the month in 7 day chunks to build the calendar.


The Twist

But wait. Remember the twist? CalendarHelper allows us to define which day is the first day of the week. If the first day of the week is not Sunday, zero, the logic breaks. When counting days the week we have a series: 0, 1, 2, 3, 4, 5, 6, representing each day from Sunday through Saturday. When we change the start of the week—say to Wednesday—we end up with the new series Wed., Thurs., Fri., Sat., Sun., Mon., Tues., or 3, 4, 5, 6, 0, 1, 2.

What we want is the offset, the number of days, from the start of the week to the date of the start of the month within that week. Using that offset we can subtract it from the first of the month to find the date of the first day of that week just like we did above.

Our offset can be thought of as counting the days of the week from 0, 1, 2...6. We need a way to convert from the rotated series 3, 4, 5, 6, 0, 1, 2 to the zero based one. Enter the modulus operator, %. Our friend the modulus operator which gives the remainder of the division of the two operands, is wonderful for repeated, bounded series. If we have a series with n -> inf and the statement n % C with C the number of elements in our series we get 0, 1, 2, ... C, 0, 1, 2, ... C ... Useful but how does this help us?


0, 1, 2, Huh?

We want to convert from 3, 4, 5, 6, 0, 1, 2 to 0, 1, 2, 3, 4, 5, 6. Let's say the start of the month falls on a Friday, 5. If we take start of the shifted series, 3, and subtract it from the size of the series, 7, and take that sum and add it to the day, 5, and modulo that with size of the series, 7, we get

= (5 + (7 - 3)) % 7
= (5 + 4) % 7
= 9 % 7
= 2

2, which is counted as the third item in the series from zero to six, which when subtracted from the start of the month gives us the date of the start of the week.

But wait. Huh?

If we were to have the series start at 0 and apply the same algorithm we have

= (5 + (7 - 0)) % 7
= 12 % 7
= 5

which is just the next repetition of our series. By subtracting 3 we are accounting for the shift factor within our original series. Mmmmm math. Tasty.

To find the last day of the week we start with the last day of the week, 6, and subtract from it the offset between the last day of the month and the first day of the week. So now we have

    first = Date.civil(options[:year], options[:month], 1)
    last = Date.civil(options[:year], options[:month], -1)
    first -= ((first.wday + (7 - options[:first_day_of_week])) % 7)
    last += 6 - ((last.wday + (7 - options[:first_day_of_week])) % 7)

Full Code

I've submitted my changes to the CalendarHelper developers. This or something similar should show up soon in the calendar helper svn.

require 'date'
require 'builder'

# CalendarHelper allows you to draw a databound calendar with fine-grained CSS formatting
module CalendarHelper

  VERSION = '0.3.0'

  # Returns an HTML calendar. In its simplest form, this method generates a plain
  # calendar (which can then be customized using CSS) for a given month and year.
  # However, this may be customized in a variety of ways -- changing the default CSS
  # classes, generating the individual day entries yourself, and so on.
  # 
  # The following options are required:
  #  :year  # The  year number to show the calendar for.
  #  :month # The month number to show the calendar for.
  # 
  # The following are optional, available for customizing the default behaviour:
  #   :table_class       => "calendar"        # The class for the <table> tag.
  #   :month_name_class  => "monthName"       # The class for the name of the month, at the top of the table.

  #   :other_month_class => "otherMonth" # Not implemented yet.
  #   :day_name_class    => "dayName"         # The class is for the names of the weekdays, at the top.
  #   :day_class         => "day"             # The class for the individual day number cells.
  #                                             This may or may not be used if you specify a block (see below).
  #   :abbrev            => (0..2)            # This option specifies how the day names should be abbreviated.
  #                                             Use (0..2) for the first three letters, (0..0) for the first, and
  #                                             (0..-1) for the entire name.
  #   :day_names         => nil               # An optional array of names for the days of the week.
  #   :first_day_of_week => 0                 # Renders calendar starting on Sunday. Use 1 for Monday, and so on.
  #   :accessible        => true              # Turns on accessibility mode. This suffixes dates within the
  #                                           # calendar that are outside the range defined in the <caption> with 
  #                                           # <span class="hidden"> MonthName</span>
  #                                           # Defaults to false.
  #                                           # You'll need to define an appropriate style in order to make this disappear. 
  #                                           # Choose your own method of hiding content appropriately.
  #
  #   :show_today        => false             # Highlights today on the calendar using the CSS class 'today'. 
  #                                           # Defaults to true.
  #   :previous_month_text   => nil           # Displayed left of the month name if set
  #   :next_month_text   => nil               # Displayed right of the month name if set
  #   :pass_builder      => false             # Pass the builder as the second argument to the block.  Ignore the
  #                                             the text that is returned from the block. You are responsible
  #                                             for adding a 'td' to the builder output.
  #
  # For more customization, you can pass a code block to this method, that will get one argument, a Date object,
  # and return a values for the individual table cells. The block can return an array, [cell_text, cell_attrs],
  # cell_text being the text that is displayed and cell_attrs a hash containing the attributes for the <td> tag
  # (this can be used to change the <td>'s class for customization with CSS).
  # This block can also return the cell_text only, in which case the <td>'s class defaults to the value given in
  # +:day_class+. If the block returns nil, the default options are used.
  # 
  # Example usage:
  #   calendar(:year => 2005, :month => 6) # This generates the simplest possible calendar.
  #   calendar({:year => 2005, :month => 6, :table_class => "calendar_helper"}) # This generates a calendar, as
  #                                                                             # before, but the <table<'s class
  #                                                                             # is set to "calendar_helper".
  #   calendar(:year => 2005, :month => 6, :abbrev => (0..-1)) # This generates a simple calendar but shows the
  #                                                            # entire day name ("Sunday", "Monday", etc.) instead
  #                                                            # of only the first three letters.
  #   calendar(:year => 2005, :month => 5) do |d| # This generates a simple calendar, but gives special days
  #     if listOfSpecialDays.include?(d)          # (days that are in the array listOfSpecialDays) one CSS class,
  #       [d.mday, {:class => "specialDay"}]      # "specialDay", and gives the rest of the days another CSS class,
  #     else                                      # "normalDay". You can also use this highlight today differently
  #       [d.mday, {:class => "normalDay"}]       # from the rest of the days, etc.
  #     end
  #   end
  #
  # An additional 'weekend' class is applied to weekend days. 
  #
  # For consistency with the themes provided in the calendar_styles generator, use "specialDay" as the CSS class for marked days.
  # 
  def calendar(options = {}, &block)
    raise(ArgumentError, "No year given")  unless options.has_key?(:year)
    raise(ArgumentError, "No month given") unless options.has_key?(:month)

    defaults = {
      :table_class => 'calendar',
      :month_name_class => 'monthName',
      :other_month_class => 'otherMonth',
      :day_name_class => 'dayName',
      :day_class => 'day',
      :abbrev => (0..2),
      :day_names => nil,
      :first_day_of_week => 0,
      :accessible => false,
      :show_today => true,
      :previous_month_text => nil,
      :next_month_text => nil,
      :pass_builder => false
    }
    options = defaults.merge options

    first = Date.civil(options[:year], options[:month], 1)
    last = Date.civil(options[:year], options[:month], -1)
    first -= ((first.wday + (7 - options[:first_day_of_week])) % 7)
    last += 6 - ((last.wday + (7 - options[:first_day_of_week])) % 7)
    today = first

    if(options[:day_names])
      day_names = options[:day_names]
    else
      i = 0
      day_names = Date::DAYNAMES.inject(Array.new(7)) { |a, d| a[(i + (7 - options[:first_day_of_week])) % 7] = d; i += 1; a }
    end

    xhtml = []
    cal = Builder::XmlMarkup.new(:target => xhtml, :indent => 2)
    cal.table(:class => options[:table_class], :border => 0, :cellspacing => 0, :cellpadding => 0) do
      cal.thead do
        cal.tr do
          if options[:previous_month_text] or options[:next_month_text]
            cal.th(options[:previous_month_text], :colspan => 3)
            colspan = 3
          else
            colspan = 7
          end

          cal.th("#{Date::MONTHNAMES[options[:month]]} #{options[:year]}", :colspan => colspan, :class => options[:month_name_class])
          cal.th(options[:next_month_text], :colspan => 3, :class => 'rightMonth') if options[:next_month_text]
        end

        cal.tr(:class => options[:day_name_class]) do
          day_names.each { |d| cal.th(d[options[:abbrev]], :scope => 'col') }
        end
      end

      cal.tbody do
        while(today <= last) do
          cal.tr do
            1.upto(7) do
              if(today.month != options[:month].to_i)
                css_class = options[:other_month_class] + ([0, 6].include?(today.wday) ? ' weekendDay' : '')
                if(options[:accessible])
                  cal.td(:class => css_class) do
                    cal << today.day.to_s
                    cal.span(Date::MONTHNAMES[d.month], :class => 'hidden')
                  end
                else
                  cal.td(today.day.to_s, :class => css_class)
                end
              else
                if(block_given? && options[:pass_builder])
                  block.call(today, cal)
                else
                  cell_text, cell_attrs = block.call(today) if block_given?
                  cell_text ||= today.day.to_s
                  cell_attrs ||= { :class => options[:day_class] }
                  cell_attrs[:class] += " weekendDay" if [0, 6].include?(today.wday) 
                  cell_attrs[:class] += " today" if (today == Date.today) and options[:show_today]  

                  cal.td(cell_attrs) { cal << cell_text }
                end
              end

              today += 1
            end
          end
        end
      end
    end

    xhtml.join
  end
end

Leave a comment

Comments