Managing Multiple Timezones for Events in Drupal

Sara Olson, Marketing Analyst
#Drupal | Posted

How does a Drupal developer handle timezones when managing events in multiple locations? How do you display the correct date and time for these various locations? David Spira, one of our Experience Strategists at Phase2, created an ingenious solution for this common dilemma. In this blog post, I will walk through how we implemented David’s solution to support multiple timezones for events, using the Date, Geofield, Geocoder and Timezone field modules.

Timezones and UX Complications

Before I get into the solution, let’s touch on some of the issues at hand. Timezone display and management are always a bit tricky for both the consumer and the editor. For the display, there’s a whole set of considerations that complicate the user experience:

  • Should we display the timezone abbreviation? What happens if two timezones have the same abbreviation (a problem that arises if your timezone support is global)?
  • Should we display the full timezone name? How do we deal with this large amount of text on teasers?
  • Should we display the UTC offset? Will the user even know the offset for his/her location?

For editing, there’s additional UX to consider:

  • How does the editor determine the timezone for the event if the default timezones for the site and the editor’s user account are different?
  • What if the event has multiple locations in different timezones? How should the editor manage this?

Our Solution

One way to simplify the process is to remove timezone names/abbreviations from both the display and the editor. The user viewing the event just wants to know the date and time for a given location, and doesn’t care about UTC offsets or timezones. Additionally, the editor might not know the timezone name for a location.If you’re creating an Event content type, then you’re going to have a Date field to store the start and end date and time information. Most likely, your event will have a location, or if it’s remote/online only, then a primary timezone for the event. For the sake of this example, let’s also assume that Events may have multiple locations, and these locations may be in completely different timezones.To start, you’ll need to install the following additional dependencies for the Geocoder, Geofield, and Timezone field modules:

The following modules (as a minimum) need to be enabled for this example:

  • ctools
  • Date
  • Date API
  • Geofield
  • Entity API
  • Geocoder
  • GeoPHP
  • Timezone field
  • Time zone field populate from Geofield

The Date Popup module (optional) provides a friendly interface for entering calendar date information. It’s a good idea to enable this module to make things easier for your editors.Now to satisfy the requirement of additional locations and timezones, we can use either a Paragraphs or Field Collection field. For either storage solution, you’ll need to add the following combination of fields:

  1. Location/Address (text, textarea, or Addressfield)
  2. Geofield based on the Address
  3. Timezone field based on the Geofield.

Your Event content type field set should look something like this (I chose to use the Date Popup widget for my Date field and the Field Collection field for the group of location, geocode, and timezone fields):Screen Shot 2015-11-17 at 11.59.13 AM

 

Exploring the Field Collection Setup

We can make the selection of the appropriate timezones for Events easier by making this determination automatically. A combination of Geofield and Timezone field fields works well for this purpose since we already have the location.To your Locations Field Collection, add a new Geofield to your Event content type, and select the Geocode from another field widget type. The field that should be used for determining the geocode should be your Address field. Select the geocoding handler you would like to use. I like using the Google Geocoder, and I use the default Point Geometry Type. The following are the defaults I used for the Geocoder Settings:Screen Shot 2015-11-17 at 12.00.48 PM

 

The next step is to link up a timezone field to this chain of dependent fields. Add a Time zone field and use the Populate from Geofield widget. Link this field to your previously created Geofield.

Here’s the field breakdown for the Field Collection:

Screen Shot 2015-11-17 at 12.01.54 PM

 

It’s a good idea to make both the Date and the Location Field Collection fields required. Adding some helpful description text to the Location and Event Date fields explaining the automatic handling of the timezone for the Event is also very user-friendly for the editor.

Adjusting the Display

Ok! We’ve got the data structure set, so now let’s adjust the display of this information. First of all, we can hide the Locations field since we’ll be handling the display of this information in our preprocess function. So the Manage Display tab for Event should look like this:Screen Shot 2015-11-17 at 12.02.58 PM

 

For this example, I created a subtheme of the Bootstrap theme. I’ll walk through the preprocessing of the full node view, and we’ll keep things relatively simple. Since I subthemed the bootstrap theme, I used Bootstrap classes to style the markup. The code examples for template.php and node--event.tpl.php are available in a zip file so you can review both files.

In template.php, I added a little helper logic so that I can target the Event content type when preprocessing the display of the node:

  1. /**
  2. * Override or insert variables into the node template.
  3. */
  4. function mt_subtheme_preprocess_node(&$variables) {
  5. $type = $variables['type'];
  6. $node_preprocess_function = 'mt_subtheme_preprocess_node_' . $type;
  7.  
  8. if (function_exists($node_preprocess_function)) {
  9. $node_preprocess_function($variables);
  10. }
  11. }

Adding this little bit of logic allows you to separate out your specific content type node preprocessing:

  1. /**
  2. * Preprocess function for Event nodes.
  3. */
  4. function mt_subtheme_preprocess_node_event(&$variables) {
  5. ...
  6. }

Ok, let’s take a look at mt_subtheme_preprocess_node_event() in further detail. For this example, I’m keeping things pretty simple and just adding some preprocessing for the full node page view. The primary datetime is set in UTC. This was because the primary event location may be different from the site or editing user’s timezone. We don’t care that the timezone associated with the saved datetime is UTC. What we really care about is the actual entered values for the start and end time for the event (the month, day, year, and time without any timezone information attached). So the first step is to get the nicely formatted datetime (Y-m-d G:i) for the start and end times.

  1. function mt_subtheme_preprocess_node_event(&$variables) {
  2. // Do some preprocessing for the full node page.
  3. if ($variables['view_mode'] == 'full' && node_is_page($variables['node'])) {
  4.  
  5. // Primary datetime is set in UTC.
  6. $utc_start_date = new DateTime('@' . $variables['field_date'][0]['value']);
  7. $utc_start_text = $utc_start_date->format('Y-m-d G:i');
  8.  
  9. $utc_end_date = new DateTime('@' . $variables['field_date'][0]['value2']);
  10. $utc_end_text = $utc_end_date->format('Y-m-d G:i');
  11.  
  12. if (isset($variables['node']->field_locations[LANGUAGE_NONE][0]['value'])) {
  13. // Get the primary timezone.
  14. $primary_location = array_shift($variables['node']->field_locations[LANGUAGE_NONE]);
  15. $primary_location = field_collection_item_load($primary_location['value']);
  16. $primary_timezone = $primary_location->field_timezone[LANGUAGE_NONE][0]['value'];

We store the primary address to make it easily to print it in the template later.

$variables['primary_location'] = $primary_location->field_location[LANGUAGE_NONE][0]['safe_value'];

We then create a new DateTime object to associate the datetime text sans-timezone with the primary location’s timezone. (The primary location’s timezone is just the first location in the field collection in this instance). We also added Exception handling just in case the timezone label used for the new DateTimeZone’s constructor was invalid.

  1. try {
  2. $primary_start_date = new DateTime($utc_start_text, new DateTimeZone($primary_timezone));
  3. $primary_end_date = new DateTime($utc_end_text, new DateTimeZone($primary_timezone));
  4. }
  5. catch(Exception $e) {
  6. watchdog('mt_subtheme_date_error',
  7. "Invalid timezone for primary timezone: %timezone",
  8. array('%timezone' => $primary_timezone), WATCHDOG_ERROR, current_path());
  9.  
  10. return;
  11. }

We also store the primary dates for the start and end times so that we can compare them to the dates for the start and end times for the additional timezones. This will be useful for determining if we should display the Month, day and year as well as the time for the additional timezones (which would be the case if another location is a day ahead or behind, for example).

  1. // We store the day for the start and end dates so that we can compare
  2. // them to the day for the start and end dates for the additional
  3. // timezones.
  4. $variables['primary_start_date'] = $primary_start_date->format('m-d-Y');
  5. $variables['primary_end_date'] = $primary_end_date->format('m-d-Y');

As a fallback we use the UTC timezone for the primary datetime.

  1. else {
  2. // Set the default timezone to UTC.
  3. $primary_start_date = new DateTime($utc_start_text, new DateTimeZone('UTC'));
  4. $primary_end_date = new DateTime($utc_end_text, new DateTimeZone('UTC'));
  5. }

Now we can address the additional locations. For this example, I used Field Collections to hold information about the additional locations. We loop through the collection, and do some additional processing. We store the processed date information for each location in the $other_dates array.

  1. $other_dates = array();
  2. foreach ($variables['node']->field_locations[LANGUAGE_NONE] as $collection) {

We have to load the field collection in order to access the entered address and calculated timezone.

$location = field_collection_item_load($collection['value']);

For each location, we process and store the following information used in the Event template:

  • address
  • Start and End dates formatted as m-d-Y. This is used to compare against the primary start and end date. If the date is different, then it makes sense to display the formatted information (see next bullet) with the time.
  • The Start and End dates formatted as l, F j, Y - G:i. This is used because I wanted to keep the text formatting the same as what would be outputted for the primary date and time.
  • The Start and End dates formatted as ISO 8601. This is used for the content attribute for the date markup. Using this just makes the markup consistent with the output for the primary date.

Note, we clone the primary date object and change the timezone appropriately for each location, and store the information.

  1. // Setup a friendly array that groups the city with the calculated time.
  2. $other_date = array(
  3. 'location' => $location->field_location[LANGUAGE_NONE][0]['safe_value'],
  4. );
  5.  
  6. // We need to clone our primary dates so that we can adjust the datetime
  7. // based on the timezone for the city.
  8. try {
  9. $primary_start_clone = clone $primary_start_date;
  10. $primary_start_clone->setTimezone(new DateTimeZone($location->field_timezone[LANGUAGE_NONE][0]['value']));
  11.  
  12. $other_date['start_date'] = $primary_start_clone->format('m-d-Y');
  13. $other_date['start_date_formatted'] = $primary_start_clone->format('l, F j, Y - G:i');
  14. $other_date['start_time'] = $primary_start_clone->format('G:i');
  15. $other_date['start_date_iso'] = $primary_start_clone->format('c');
  16.  
  17. $primary_end_clone = clone $primary_end_date;
  18. $primary_end_clone->setTimezone(new DateTimeZone($location->field_timezone[LANGUAGE_NONE][0]['value']));
  19.  
  20. $other_date['end_date'] = $primary_end_clone->format('m-d-Y');
  21. $other_date['end_date_formatted'] = $primary_end_clone->format('l, F j, Y - G:i');
  22. $other_date['end_time'] = $primary_end_clone->format('G:i');
  23. $other_date['end_date_iso'] = $primary_end_clone->format('c');
  24. }
  25. catch(Exception $e) {
  26. watchdog('mt_subtheme_date_error',
  27. "Invalid timezone for additional location: %timezone",
  28. array('%timezone' => $primary_timezone), WATCHDOG_ERROR, current_path());
  29.  
  30. continue;
  31. }
  32.  
  33. $other_dates[] = $other_date;
  34. } // Closing foreach loop.
  35.  
  36. $variables['other_dates'] = $other_dates;

Finally, for some icing on the cake, we can present the user with an additional option to find out the date and time based on the selected timezone. You can use Chosen module to make the select dropdown more user-friendly.

$variables['my_timezone_form'] = drupal_get_form('mt_subtheme_my_timezone_form', $primary_start_date, $primary_end_date);

Here’s what the form looks like:

  1. /**
  2. * My Timezone form builder.
  3. */
  4. function mt_subtheme_my_timezone_form($form, &$form_state, $primary_start_date, $primary_end_date) {
  5. $form = array();
  6.  
  7. $form['#primary_start_date'] = $primary_start_date;
  8. $form['#primary_end_date'] = $primary_end_date;
  9.  
  10. $form['my_timezone'] = array(
  11. '#type' => 'select',
  12. '#options' => mt_subtheme_system_time_zones(),
  13. '#prefix' => '<h5 id="my-timezone-label">Select My Timezone</h5>',
  14. '#ajax' => array(
  15. 'callback' => 'mt_subtheme_my_timezone_callback',
  16. 'wrapper' => 'my-timezone-label',
  17. 'method' => 'replace',
  18. 'effect' => 'fade',
  19. ),
  20. );
  21.  
  22. return $form;
  23. }

The list of timezones come from basically the list of system timezones, made slightly prettier by replacing ‘_’ with ‘ - ’.

  1. /**
  2. * Generate an array of time zones and their local time & date.
  3. */
  4. function mt_subtheme_system_time_zones() {
  5. $zonelist = timezone_identifiers_list();
  6. $zones = array('' => t('- None selected -'));
  7. foreach ($zonelist as $zone) {
  8. // Because many time zones exist in PHP only for backward compatibility
  9. // reasons and should not be used, the list is filtered by a regular
  10. // expression.
  11. if (preg_match('!^((Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific)/|UTC$)!', $zone)) {
  12. $zone_pieces = explode('/', str_replace('_', ' ', $zone));
  13. $zone_pieces = array_reverse($zone_pieces);
  14. $continent = count($zone_pieces) > 1 ? array_pop($zone_pieces) : '';
  15. $zone_pretty = implode(', ', $zone_pieces);
  16.  
  17. if ($continent) {
  18. $zone_pretty .= ' - ' . $continent;
  19. }
  20.  
  21. $zones[$zone] = t('@zone', array('@zone' => $zone_pretty));
  22. }
  23. }
  24. // Sort the translated time zones alphabetically.
  25. asort($zones);
  26. return $zones;
  27. }

Display of the information is handled by a simple Ajax callback:

  1. /**
  2. * Callback for My Timezone select.
  3. */
  4. function mt_subtheme_my_timezone_callback($form, &$form_state) {
  5. $timezone = $form_state['values']['my_timezone'];
  6.  
  7. try {
  8. $primary_start_clone = clone $form['#primary_start_date'];
  9. $primary_start_clone->setTimezone(new DateTimeZone($timezone));
  10. $start = $primary_start_clone->format('m/d/Y -
  11. G:i');
  12.  
  13. $primary_end_clone = clone $form['#primary_end_date'];
  14. $primary_end_clone->setTimezone(new DateTimeZone($timezone));
  15. $end = $primary_end_clone->format('m/d/Y -
  16. G:i');
  17. }
  18. catch(Exception $e) {
  19. watchdog('mt_subtheme_date_error',
  20. "Invalid timezone given to My Timezone form: %timezone",
  21. array('%timezone' => $timezone), WATCHDOG_ERROR, current_path());
  22.  
  23. return t('<h5 id="my-timezone-label">Error determining the time based on the selected timezone.</h5>');
  24. }
  25.  
  26. return t('<h5 id="my-timezone-label">@start to @end</h5>', array(
  27. '@start' => $start,
  28. '@end' => $end,
  29. ));
  30. }

In node--event.tpl.php, we render field_date and print the $primary_location.

  1. <div class="date-locations">
  2. <?php // Display the primary datetime and location. ?>
  3. <div class="panel panel-default first location">
  4. <div class="panel-heading">
  5. <h3 class="panel-title">Primary Event Date and Time</h3>
  6. </div>
  7. <div class="panel-body">
  8. <?php print render($content['field_date']); ?>
  9. in <strong><?php print $primary_location; ?></strong>
  10. </div>
  11. </div>

We loop through the $other_dates array, and print out the information. There’s a little logic to determine if we need to print out the month, day, and year if different from the primary date or if the date spans multiple days.

  1. <div class="other-locations">
  2. <h4>Other Locations and Times</h4>
  3. <?php foreach($other_dates as $date): ?>
  4. <div class="location well well-sm">
  5. <?php
  6. // We need to check and handle cases in which the day (and possibly
  7. // year) is different from the primary date. We should also match the
  8. // markup produced from rendering a date field too.
  9. ?>
  10. <?php if (($primary_start_date != $date['start_date']) || ($date['start_date'] != $date['end_date'])): ?>
  11. <?php if (($primary_end_date != $date['end_date']) || ($date['start_date'] != $date['end_date'])): ?>
  12. <div class="date-display-range">
  13. <span class="date-display-start" property="dc:date" datatype="xsd:dateTime" content="<?php print $date['start_date_iso']; ?>"><?php print $date['start_date_formatted']; ?></span>
  14. to
  15. <span class="date-display-end" property="dc:date" datatype="xsd:dateTime" content="<?php print $date['end_date_iso']; ?>"><?php print $date['end_date_formatted']; ?></span>
  16. </div>
  17. <?php else: ?>
  18. <?php // Match markup that's created from rendering a date field...even the span > div ?>
  19. <span class="date-display-single"><?php print $date['start_date_formatted']; ?>
  20. <div class="date-display-range">
  21. <span class="date-display-start" property="dc:date" datatype="xsd:dateTime" content="<?php print $date['start_date_iso']; ?>"><?php print $date['start_time']; ?></span>
  22. to
  23. <span class="date-display-end" property="dc:date" datatype="xsd:dateTime" content="<?php print $date['end_date_iso']; ?>"><?php print $date['end_time']; ?></span>
  24. </div>
  25. </span>
  26. <?php endif; ?>
  27.  
  28. <?php else: ?>
  29. <?php // The date is the same. Let's just display the time. ?>
  30. <span class="date-display-single">
  31. <div class="date-display-range">
  32. <span class="date-display-start" property="dc:date" datatype="xsd:dateTime" content="<?php print $date['start_date_iso']; ?>"><?php print $date['start_time']; ?></span>
  33. to
  34. <span class="date-display-end" property="dc:date" datatype="xsd:dateTime" content="<?php print $date['end_date_iso']; ?>"><?php print $date['end_time']; ?></span>
  35. </div>
  36. </span>
  37.  
  38. <?php endif; ?>
  39. in <strong><?php print $date['location']; ?></strong>
  40. </div>
  41. <?php endforeach; ?>
  42. </div>
  43. </div>

You can add more logic for a more sophisticated display for the month, day, and year so that less unnecessary text is displayed to the user.Finally, we render the form that allows the user to see the date and time for the selected timezone.

<?php print render($my_timezone_form); ?>

And that’s it! Here’s a couple examples of the node edit form and the resulting output:

Single Day Event

Screen Shot 2015-11-17 at 12.18.00 PM

Multi-Day Event + My Timezone Results

 

Screen Shot 2015-11-17 at 12.20.40 PM

And that's a wrap! Here's a link to the zip file for the above code sample.

Sara Olson

Marketing Analyst