Time Slash/er

Where in the World is 5 o'clock?

EXPERIMENTAL
TimeZonesBoy [Public domain] & Nik Frey (niksan) [CC BY 2.5 (https://creativecommons.org/licenses/by/2.5)]

A PHP tutorial through Time, Space and Alcohol

The saying "It must be five o'clock somewhere!" is (apparently!) at least seven decades old and many famous people have been heard saying a variation of it, most notable for the purpose of this tutorial is Slash from Guns'n'Roses maybe just because allows me to call this useless piece of code the "Time Slasher".

I am calling it useless, but I have to say that some people made good examples with maps and all (I personally like this one at backuppint.com) and I don't feel good judging how other people waste their time. If you want to see how I wasted mine, toggle the experimental switch on the left column to ON.

But how do you do something like this in PHP?

There are many ways to achieve the same result, but I am gonna stick to OOP with objects DateTime and DateTimeZone (read more at php.org).

<?php
    $all_possible_timezones_names = DateTimeZone::listIdentifiers();

    $date_time_zone_object_of_first_timezone = new DateTimeZone($all_possible_timezones_names[0]);

    $local_time = new DateTime('now', $date_time_zone_object_of_first_timezone);

In the example above DateTimeZone::listIdentifiers() gives a list of all available timezones such as ["Africa/Abidjan", "Africa/Accra", ..., "Europe/Londond", ...etc.] from which we could create various DateTimeZone objects. In this example we have created one such object from the first timezone in the array and then out of the blue we created a new DateTime set to right now, but in the particular timezone we have passed to it.

As I have said there are many ways to go about this thing, but my favourite is the brute force that loops through every timezone and gets the current time there allowing me to show you a couple of noteworthy array functions.

PS: the brute force approach costs a whooping 14 milliseconds of execution time.

The code

The entire class is a bit over 70 lines of code and here it is:

<?php // TimeSlasher.php
class TimeSlasher {
    private $now;
    private $hour;
    private $selected = [];
    private $random;

    public function __construct($hour = 17) {
        $this->now = new DateTime("now", new DateTimeZone('UTC'));
        $this->hour = $hour;
    }

    public function whereToDrink(){
        $timezones = $this->fetchTimezones();

        $this->selected = array_values(
            array_filter($timezones, function($item){
                return $item['time']->format('H') == $this->hour;
            })
        );

        $this->random = count($this->selected) ? $this->selected[mt_rand(0, count($this->selected) - 1)] : null;

        return $this;
    }

    public function getSelected(){
        return $this->selected;
    }

    public function getRandom($formatted = true){
        $random = $this->random;
        if(!$random){
            return 'Nowhere in the world is ' . $this->fakeAmTime($this->hour) . ' o\'clock, but still';
        }

        if($formatted){
            return 'The time in ' . $random['city'] . ' (' . $random['region'] . ') is ' . $random['formatted_time'];
        }

        return $random;
    }

    private function fakeAmTime($hour){
        if($hour > 12) {
            return $hour - 12;
        }

        return $hour;
    }

    private function fetchTimezones(){
        return array_map(
            function($item){
                $timezone = new DateTimeZone($item);
                $local_time = new DateTime('now', $timezone);
                $name_split = explode('/', $item);

                return [
                    'name' => $item,
                    'offset' => $timezone->getOffset($this->now),
                    'time' => $local_time,
                    'formatted_time' => $local_time->format('h:ia'),
                    'region' => $name_split[0],
                    'city' => isset($name_split[1]) ? str_replace('_', ' ', $name_split[1]) : 'UTC',
                ];
            },
            DateTimeZone::listIdentifiers()
        );
    }
}


Save all that in a file called TimeSlasher.php and in your faithful index.php include it, init the class and call the relevant methods such as...

<?php //index.php
include 'TimeSlasher.php';

$slash = new TimeSlasher();

echo $slash->whereToDrink()->getRandom() . ', it\'s time to drink!';

How does it work?

Let's start from the bottom with the method fetchTimezones() that uses the wonderful array_map to go through all timezones names in PHP and returns an array of elements passed through our custom function: in our case we create a timezone object such as DateTimeZone(single_time_zone), a DateTime for now in that timezone and we reformat every item of the array from a string to a usable array.

// TimeSlasher.php
private function fetchTimezones(){
   // array_map signature is something like array_map(transforming_function, array_to_be_transformed)
   return array_map(
       // transforming_function
       function($item){ // $item is a string such as "Africa/Abidjan"
           // A single timezone  object!
           $timezone = new DateTimeZone($item);
           // The time and date right now in that particular timezone
           $local_time = new DateTime('now', $timezone);
           // we split the name to divide the ragion or continent (Africa) from the actual place (Abidjan)
           $name_split = explode('/', $item); 

           return [
               // the original item (Africa/Abidjan)
               'name' => $item,
               // USELESS in this example but you should know that you can calculate the exact offset with this
               'offset' => $timezone->getOffset($this->now),
               // what time is in that timezone?
               'time' => $local_time,
               // this is here only as an example, as we are passing $local_time this could be calculated only for a selection of timezones, 
               // not all of them as we are doing in this array_map
               'formatted_time' => $local_time->format('h:ia'),
               // Africa!
               'region' => $name_split[0],
               // UTC is the only timezone without slashes and would generate a notice without this check, 
               // the place could contain underscores that should be replaced with spaces (New_York is an example of this)
               'city' => isset($name_split[1]) ? str_replace('_', ' ', $name_split[1]) : 'UTC', 
           ];
       },
       // array_to_be_transformed (PS: includes all timezones names)
       DateTimeZone::listIdentifiers()
   );
}

If you want to see the result of this method change it from private to public and in index.php var_dump it or echo it as a JSON string:

<?php //index.php
include 'TimeSlasher.php';

$slash = new TimeSlasher();

echo $slash->whereToDrink()->getRandom() . ', it\'s time to drink!';

// dump the result of fetchTimezones!
var_dump( $slash->fetchTimezones() );

// or echo it json_encoded
echo '<pre>' . json_encode( $slash->fetchTimezones(), JSON_PRETTY_PRINT ) . '</pre>'; // Pretty printed for humans!

The result of one of the items is a quite complex object and it uses a bit more resources than we really need: a key identifying the place and the current time in that timezone (["CONTINENT/PLACE" => DateTime]).

In JSON (JS eval'd) should look like this:

{
    name:"Africa\/Abidjan",
    offset:0,
    time:{
        date:"2019-03-22 21:42:04.271228",
        timezone_type:3,
        timezone:"Africa\/Abidjan"
    },
    formatted_time:"09:42pm",
    region:"Africa",
    city:"Abidjan"
}

What all this method does is fetching and formatting the data so that we can actually use it. We could already have applied filters and reduce the resulting array, but that's not the point of this tutorial. Let's move on to the next method, the first called after instantiating the class in index.php:

// TimeSlasher.php
public function whereToDrink(){
    // fetch the formatted data from the previous method!
    $timezones = $this->fetchTimezones();

    // Property SELECTED will hold an array of filtered timezones, array_values resets all keys :)
    $this->selected = array_values(
        // similar to array_map, array_filters returns an new array iterating through every item
        // but instead of modifying the values it keeps the ones for which the function returns true
        array_filter($timezones, function($item){
            // comparing the HOUR to our expected one
            return $item['time']->format('H') == $this->hour; // $this->hour is inited in construct
        })
    );

    // Property RANDOM will hold a random timezone out of the filtered ones or NULL if none are found  
    $this->random = count($this->selected) ? $this->selected[mt_rand(0, count($this->selected) - 1)] : null;

    // In OOP returning $this (representing the class) allows you to concatenate methods
    return $this;
}

Methods getRandom() and getSelected() simply returns formatted properties of the class after the transformations happening in whereToDrink() and are not really interesting, fakeAmTime() does not deserve a minute of our time, but please note the constructor: apart from a quite useless $now assignment, it accepts an integer on init that could be used to ask important questions such as where in the world is 3 in the morning? by running the class as...

 // index.php
$slash = new TimeSlasher(3); // this is the magic number!

Conclusions

We have seen a couple of interesting classes in PHP for managing dates and some nice array functions. Just to clarify I did try a very much simplified version of this code and I could tell you a couple of things I noticed:

  • array_filter and array_map are as fast as for loops,
  • you could loop and filter at the same time to work with a smaller array and skip array_values
  • You could format dates, explode and str_replace strings of filtered results only
  • if you don't need the $this->selected property you could skip it
  • you could cache results, especially if you don't intend to show realtime times from this class (pun necessary)
  • you could find creative ways to use the getOffset() method of DateTimeZone
  • doing so you could get from 14 milliseconds down to 12, that in programming represents the lowest item on the lowest list of potential optimizations.

By the way, right now is 23:50 in GMT +1 or as our script would say... The time in Chicago (America) is 05:50pm, it's time to drink!

For realtime updates toggle the experimental switch on the left column to ON and refresh a couple of times: you should see a version of this code (rewritten as a Pico plugin) in action.