Building a Schedule with D3

Will Holcomb

11 December 2013

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 divs for each week with an ordered lists for each day. The first thing to do is generate the week divs. 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
        }
      } )