Recently the One Acre Cafe opened in Johnson City. It is a non-profit community café and, due to budget constraints, they are without software to manage their volunteer staffing.
I thought I would do a simple rails app (live) to try and address the problem. For rendering the calendar I decided to do it client-side using D3. My initial version was in SVG, based on the Calendar View example. The SVG layout relied on static block sizes, so I reimplemented it in HTML.
The data model is relatively straightforward. There are a set of tasks with a name and icon, then shifts are for a task with a start and end times. The program begins by downloading the list of tasks and restructuring them to be accessible by id:
d3.json( '/tasks.json', function( error, tasks ) { var newTasks = {} tasks.forEach( function( t ) { newTasks[t.id] = t } ) tasks = newTasks ⋮
The json
method is much like jQuery's getJSON
method in that it does an asynchronous request and calls the handler with the parsed object.
There are two possible endpoints for the shifts: shifts
which returns all shifts and shifts/open
which returns only shifts in the future which still need workers. To get the appropriate set I used the window.location.pathname
:
Dates are not automatically converted into javascript objects, so the first step is to parse them:
Next I used the min
and max
functions to find the range the calendar should cover. D3's intervals are inclusive, so they give all dates within a range. This means that to include the first shift, the start date needs to be moved back a week:
var start = new Date( d3.min( shifts, function( d ) { return d.start } ) ) var end = new Date( d3.max( shifts, function( d ) { return d.end } ) ) start.setDate( start.getDate() - 7 )
Note that new Date
is used to clone the returned date. D3 returns a reference, so without the clone setting the date on the start would change the property of the shift.
The structure of the page is div
s for each week with an ordered lists for each day. The first thing to do is generate the week div
s. They are appended to a div
with an id of shifts
that is already in the page.
var weeks = d3.select( '#shifts' ).selectAll('.week') .data( function( d ) { return d3.time.weeks( start, end ) } ) .enter() .append( 'div' ) .attr( { class: 'week', week: week, month: monthNumber, year: year, } )
The data
method specifies dataset to use to generate the elements. The weeks
method returns an array of the the Sundays between start
and end
. The enter
method calls the chained methods for each member of the dataset. Some additional information is encoded in the div
attributes. They rely on D3's format methods which take a date and return a formatted string.
var week = d3.time.format( '%U' ), monthNumber = d3.time.format( '%m' ), year = d3.time.format( '%Y' )
Adding the div
for each day uses the data
and enter
methods again:
var days = weeks.selectAll( '.day' ) .data( function( d ) { return d3.time.days( d, nextWeek( d ) ) } ) .enter() .append( 'div' ) .attr( { class: 'day', day: day, weekday: weekday } )
The argument to the data
method is each of the dates from the weeks. The nextWeek
method simply adds a week to the given date:
var nextWeek = function( d ) { var nextWeek = new Date( d ) nextWeek.setDate( d.getDate() + 7 ) return nextWeek }
There is a label for the months that is added to the weeks element after the day div
:
weeks .filter( function( d, i ) { return i == 0 || ( month( d ) != month( nextWeek( d ) ) ) } ) .append( 'div' ) .classed( 'month', true ) .text( function( d ) { return month( nextWeek( d ) ) } )
The filter
method reduces the weeks array to elements to the first week and the weeks where a month begins. The classed
method sets the class attribute to month
. The month names are rotated to line up with the edge of the calendar with the following CSS:
.month { transform: rotate(90deg); transform-origin: bottom left; -ms-transform: rotate(90deg); /* IE 9 */ -webkit-transform: rotate(90deg); /* Safari and Chrome */ -webkit-transform-origin: bottom left; }
Days also have a label which is simply the date. Generating those is simple:
var titles = days .append( 'div' ) .classed( 'title', true ) .text( day )
I want the schedule for each week to start and end based on the data for that week. The following code finds those bounds:
var weekBounds = d3.nest() .key( function( d ) { return week( d.start ) } ) .rollup( function( d ) { return { start: d3.min( d, function( d ) { return d.start.getHours() } ), end: d3.max( d, function( d ) { return d.end.getHours() } ), } } ) .map( shifts )
The key
functions groups the data and produces an array of objects with a name
property set to the value returned by key
and a values
property that is an array of the matching entries. This code groups entries by the week number of the start time.
The rollup
function compacts the array returned by key
. It is called with the array from the values
property. This code finds the minimum and maximum hours within the week.
Finding the hours for a given day looks like this:
var hoursForDay = function( d ) { var start = new Date( d ), end = new Date( d ) start.setHours( weekBounds[week( d )].start ) end.setHours( weekBounds[week( d )].end ) return d3.time.hours( start, end ) }
There is an unordered list for each hour. The structure should look familiar:
var hours = days .filter( function( d ) { return week( d ) in weekBounds } ) .selectAll( '.hour' ) .data( hoursForDay ) .enter() .append( 'ul' ) .attr( { class: 'hour', hour: hour } )
Shifts need to be added to the appropriate hours. To do this the data is again processed with the key
and rollup
methods:
var shiftStarts = d3.nest() .key( function( d ) { return d.start } ) .rollup( function( d ) { return d } ) .map( shifts )
The structure of each hour follows this basic template:
The list item and label are generated with the following code:
var shifts = hours .filter( function( d ) { return d in shiftStarts } ) .classed( 'open', true ) .selectAll( '.shift' ) .data( function( d ) { return shiftStarts[d] } ) .enter() .append( 'li' ) .classed( 'shift', true ) .classed( 'taken', function( d ) { return d.taken } ) .on( 'dblclick', function( d ) { window.location = d.url } ) .append( 'label' )
This illustrates an alternate version of the classed
method which only adds the taken
class if the taken property of the shift is set. The on
method operates much like jQuery's.
The last step is adding the radio button and image. That is a simple append:
shifts .append( 'input' ) .attr( { type: 'radio', name: function( d ) { return 'shift[' + d.start + ']' } } ) shifts .append( 'img' ) .classed( 'icon', true ) .attr( { src: function( d ) { return tasks[d.task_id] ? tasks[d.task_id].icon : null } } )