Handling timezones – how not to do it!

Last week, I had quite an interesting issue to deal with. The site had a custom post type for webinars. When you are registered, you were supposed to see a “Join” button some minutes before the webinar starts. There was also a setting in the backend to set the number of minutes, the button should be shown before the event.

For some reason, it was not working. As I had identified another issue with time calculations on this plugin, I quickly set the time to 300 minutes before the event, since the webinar had already started 5 minutes ago. This fixed the issue for the moment. But why has it happened at all? Investigating the issue showed some “clever code”.

Investigating the data

The plugin was using data from an external API. This API returned multiple fields, which were saved into postmeta. These are some of the fields that were used:

+---------+-------------------------------+------------------------------+
| post_id | meta_key                      | meta_value                   |
+---------+-------------------------------+------------------------------+
| 1234567 | webinar_created_by            | api                          |
| 1234567 | event_id                      | 12345                        |
| ...     | ...                           | ...                          |
| 1234567 | to_date                       | Montag, 27. März 2023        |
| 1234567 | date                          | Montag, 27. März 2023        |
| 1234567 | date_en                       | 27 Mar 2023                  |
| 1234567 | to_date_en                    | 27 Mar 2023                  |
| 1234567 | start_time                    | 19:00:00                     |
| 1234567 | end_time                      | 20:45:00                     |
| 1234567 | time_zone                     | Mitteleuropäische Sommerzeit |
| ...     | ...                           | ...                          |
+---------+-------------------------------+------------------------------+

Can you recognize something here? Yes, this is localized data. The timezone “Mitteleuropäische Sommerzeit” is the German name for “Central European Summer Time” (CEST, GMT+2). So the time is two hours ahead of UTC. That’s why I’ve set a value greater than 120 minutes, which safely fixed it. But when the timezone is set correctly, why does it fail?

Converting timezone strings

In that plugin, there was a custom function converting timezone strings to UTC offsets. The function looked something like this:

function get_timezone_mapping( $name = '' ) {
	$mapping = [
		// ...
		'Central Time' => '-6',
		'Central Standard Time' => '-6',
		'Canada Central Standard Time' => '-6',
		// ...
		'Portugal Winter Time' => '+0',
		'India Standard Time' => '+05:30',
		// ...
		'Восточноевропейское время' => '+2',
		'Eastern European Summer Time (Athens)' => '+3',
		'Eastern European Summer Time' => '+3',
		// ...
		'北京时间' => '+8',
		'台北時間' => '+8',
		'Seoul Time' => '+9',
		'日本時間' => '+9',
	];

	if ( ! empty( $name ) ) {
		if ( ! empty( $mapping[ $name ] ) ) {
			return $mapping[ $name ];
		} else {
			return false;
		}
	}

	return $mapping;
}

You would pass the strings of a timezone, and it would return the UTC offset. For some timezones, there were different variants, some even translated into other languages. Only our “Mitteleuropäische Sommerzeit” was not in that list, so it returned false in this case.

When things broke

Now, this function and its return value was used to create a Date object. It was then compared to the current time (of the server). The codes looked something like this (simplified):

// Data dynamically queried from the database.
$webinar_data = [
	'time_zone' => 'Mitteleuropäische Sommerzeit',
	'date_en' => '27 Mar 2023',
	'start_time' => '19:00:00',
];
// ...
$timezone = $webinar_data['timezone'];
$start_date = $webinar_data['date_en'];
$start_time = $webinar_data['start_time'];
// ...
$timezone_mapping = get_timezone_mapping( $timezone );
$date_timezone = ! empty( $timezone_mapping ) && ! is_array( $timezone_mapping ) ? new DateTimeZone( $timezone_mapping ) : null;

$current_datetime = new DateTime( 'now', $date_timezone );
$start_datetime = $start_date ? new DateTime( $start_date . ' ' . $start_time, $date_timezone ) : null;
// ...
$pre_buffer_minutes = empty($minutes) ? 15 : absint($minutes);
// ...
// Subtract the buffer from the webinar's starting date & time.
$start_datetime->sub( new DateInterval( "PT{$pre_buffer_minutes}M" ) );
$is_within_time = $current_datetime->getTimestamp() >= $start_datetime->getTimestamp();

So let’s break it down and see where the issue lies. The webinar’s timezone is passed to the function get_timezone_mapping(), but since the string “Mitteleuropäische Sommerzeit” cannot be mapped, this function returns false, which then results in using null for $date_timezone on line 13. This timezone is then used in both new Date() calls. But what happens here? Let’s take a look at the resulting Date object of the two calls and which time they represent, when being called at “19:00” (server time):

$current_datetime = (new DateTime('now', null))->format('Y-m-d H:i:s');
// 2023-03-27 17:00:00
$start_datetime = (new DateTime('27 Mar 2023 19:00:00', null))->format('Y-m-d H:i:s');
// 2023-03-27 19:00:00

Since the second Date object $start_datetime represents a valid “date string”, PHP will create an object with that exact time, ignoring the timezone. The first Date object $current_datetime however is using the now string and also no valid DateTimeZone object passed as a second parameter. If none is passed, PHP will always use UTC. So even if the server (or WordPress system) runs on Central European Summer Time, PHP will still use UTC. That results in the two-hour difference between the time we are expecting to get and the one we actually get. And finally, when the $is_within_time boolean gets created, it will be false, until $pre_buffer_minutes plus our two-hour difference if reached.

How to deal with timezones in a better way?

As you can see from the code above, handling timezones in such a way is a really bad idea. Especially, when you also have translated timezone strings. You can’t possibly maintain an array with all different variants. So what to use instead?

I would suggest to always use an “industry standard” string including the timezone, when you specify a time and date. In the table at the beginning of this blog post, you can see the date written in two ways: “Montag, 27. März 2023” and “27 Mar 2023”. The first one is translated, the second one is not. But by splitting up the date, time and timezones into different values, you have to combine them again (as seen at line 16), in order to create “date objects” in different programming languages. Why not use a format like this instead, having all the parts combined?

$webinar_start = 'Mon, 27 Mar 2023 19:00:00 +0200';

This string combines all information about the time, date and timezone in one string. This is what you would get, when you use the $date->format('r') output format. It follows the standards RFC 2822/RFC 5322. When someone consumes your API, they can then use this string, create a “date object” from it, and get the time, date, or whatever they might need from it.

Conclusion

Dealing times and dates is often not easy to do in programming. If you operate on different timezones as well, things become even harder. You can decide to serve parts of your time/date information as separate values, but please make sure to also provide a string like this. And if you decide not to do it, then please, don’t translate timezones, month names, or anything similar, as it can really cause (sometimes hard to debug) issues, as the one explained here.

As for this plugin, I have yet to find a solution. If the API doesn’t add such a string in the future, I probably have to extend the static array in that function with more and more (translated) timezone strings, but I would never be sure, if not one day, a new value would become available I don’t have, causing the issue all over again.

Posted by

Bernhard is a full time web developer who likes to write WordPress plugins in his free time and is an active member of the WP Meetups in Berlin and Potsdam.

1 comment » Write a comment

  1. Hey Bernhard,

    Yes this system hasn’t been coded with much regard for any changes that need to make, well done for finding a solution, even if it means doing something that isn’t perfectly optimal!

Leave a Reply

Your email address will not be published. Required fields are marked *