On Archive.fm I wanted to display a calendar which shows the percentage complete of any day in the history of podcasting. I want to know if my hardware is keeping up, making progress on the immense backlog, or falling ever further behind.
Rendering a calendar-style view of a month is a remarkably fiddly operation. At a minimum, the display logic needs to track:
- Which day of the week the display starts/ends on
- Which days have additional content — in this case the percentage of content transcribed for the day
- Rendering the headers and day numbers
- Placing the “cells” representing each day on the display underneath the correct day of the week
- Rendering the cells which aren’t actually part of the current month, but need to be on screen to make the month look correct.
Just like any datetime algorithm, when all of this is stacked on top of each other, it gets hairy quickly. I decided to tackle the last point with a custom iterator in Crystal. I didn’t want to have to deal with rendering the parts of a week which fall before or after the current month. Leaving that constraint out I could easily handle the wrapping and other display logic inline and the implementation is readable:
<h1><%= presenter.month_header %></h1>
<table>
<% presenter.days_of_the_month.each do |date| %>
<% if date.monday? %>
<tr>
<% end %>
<td>
<% if presenter.in_month? date %>
<%= date.to_s("%-d") %>
<br>
<% percent_transcribed_for date %>
<% else %>
<span style="color: #ccc"><%= date.to_s("%-d") %></span>
<% end %>
</td>
<% if date.sunday? %>
</tr>
<% end %>
<% end %>
</table>
The implementation of the presenter method days_of_the_month
and the iterator it builds allow the above code to forget about the messy situation of a month which starts on Wednesday and what to to about the days in that week before Wednesday. Here’s what I came up with:
module Presenter
class Calendar
def initialize(@first_of_month : Time)
end
def days_of_the_month
RenderableMonthIterator.new(@first_of_month)
end
end
# Iterates the days of the month, and including the "padding" days both
# before the start of the month and after the end which are needed to
# visually render a calendar.
class RenderableMonthIterator
include Iterator(Time)
def initialize(@first_of_month : Time)
@current_day = @first_of_month.at_beginning_of_week
@last_day = @first_of_month.at_end_of_month.at_end_of_week
@first = true
end
def next
if @first
@first = false
return @current_day
end
@current_day = @current_day + 1.day
if @current_day > @last_day
stop
else
@current_day
end
end
end
end
Crystal stdlib provides some handy convenience mutators on Time objects which make the setup trivial: at_beginning_of_week
, at_end_of_month
, and at_end_of_week
.
The best part about this is that there’s nothing complicated in front of me at the end of the day – it’s all readable, single-purpose code.