11
votes

DateTime::Diff should calculate a proper interval and take into account Daylight Savings Time (DST) and leap years. Although apparently it isn't so. Code of horror:

$d1 = new DateTime("2011-10-30 01:05:00", new DateTimeZone("Europe/Stockholm"));
$d2 = new DateTime("2011-10-30 03:05:00", new DateTimeZone("Europe/Stockholm"));

echo $d1->getOffset() / (60 * 60);

Prints '2'! Keep in mind thus that UTC time = 1h - 2h = 23:05:00 the day before.

echo $d2->getOffset() / (60 * 60);

Prints '1'. DST happened. UTC time = 3h - 1h = 02:05:00.

$di = $d1->diff($d2);
echo "Hours of DateInterval: " . $di->h;

Prints '2'! Wrong?

$hoursofdiff = ($d2->getTimeStamp() - $d1->getTimeStamp()) / 60 / 60;
echo "Calculated difference in hours: $hoursofdiff";

Prints '3'! Correct?

When the clock turned 03:00:00 at the given date, all Swedes turned their clock back one hour to 02:00:00. That means that total amount passed between 01:05 until 03:05 is three hours, much like the manual calculation echo'ed when using the UNIX TimeStamp. And much like we calculates on our fingers if we use an analogue clock. Even more so when we calculate the difference between the two UTC timestamps I got using PHP's own logic of offsets (!).

Is it PHP or have my brain ceased to work properly? A reprimand from anyone of you all gods that exist on this site would make me so happy!

I'm using PHP 5.4 (VC9) on an Apache-server. Unfortunately I use Windows 7 x64 as OS. I have tested my setup against all claims of bugs in PHP's Date/Time classes (there are a couple related to Windows) and can confirm that my system have none of them. Except from the above stated code I have not found any other errors. I pretty much validated all code and output the book "PHP Architect's guide to date and time programming" had to offer. Therefore I must conclude it has to be my brain witch has defaulted but I thought I'd give it a shoot here first.

3
Same on 5.3.6 on OS X and 5.3.9 on UbuntuPhil
Also effects DateTime::sub() and DateTime::add()Phil

3 Answers

9
votes

You're right, PHP currently doesn't handle DST transitions...

Bug reports #51051 (still open) and #55253 (fixed in PHP 5.3.9) describe the problems you're having.

Daniel Convissor has written an RFC trying to address the issue a while back but the change-logs don't suggest this has been addressed. I was hoping this would fixed in 5.4 but I don't see any evidence it has been.

When/if it is implemented, it looks like you'll have to append "DST" or "ST" to the time string.

Best practice is to do all your date calculations in UTC, which avoids this problem.

This DST best practices post is very informative too.

1
votes

Alright I got a wrapper class working. It calculates real time passed. First it compares the offsets from UTC and add or subtract this time difference to the datetime-object passed as an argument. Thereafter it need not do anything more than to call parent::diff. Well ok I needed to introduce a one-liner to hack what could be yet another bug in PHP (see source code below). The DateTimeDiff:diff method calculates REAL time passed. In order to understand what that means, I advise you to test this class using various different dates and times and to aid your workload I also included at the bottom of this comment a rather simple HTML-page I wrote. This link could be a good starting point to get some ideas for date and time combinations:

https://wiki.php.net/rfc/datetime_and_daylight_saving_time

Moreover, take note that when we have a backward transition in DST, some date/time combinations can belong to both timezones. This ambiguity can make the results of this class differ from what was expected. Thus if you're seriously thinking about using this class, develop it further and ask for user clarification in these cases.

Here you are, the class:

<?php
class DateTimeDiff extends DateTime
{
    public function diff($datetime, $absolute = false)
    {
    // Future releases could fix this bug and if so, this method would become counterproductive.
    if (version_compare(PHP_VERSION, '5.4.0') > 0)
        trigger_error("You are using a PHP version that might have adressed the problems of DateTime::diff", E_USER_WARNING);

    // Have the clock changed?
    $offset_start = $this->getOffset();
    $offset_end   = $datetime->getOffset();

    if ($offset_start != $offset_end)
    {
        // Remember the difference.
        $clock_moved = $offset_end - $offset_start;

        // We wouldn't wanna mess things up for our caller; thus work on a clone.
        $copy = clone $datetime;


        if ($clock_moved > 0)
        {
            $timestamp_beforesub = $copy->getTimestamp();

            // Subtract timedifference from end-datetime should make parent::diff produce accurate results.
            $copy->sub( DateInterval::createFromDateString("$clock_moved seconds") );

            // No change occured; sometimes sub() fails. This is a workable hack.
            if ($timestamp_beforesub == $copy->getTimestamp())
                $copy->setTimezone(new DateTimeZone("UTC"));
        }

        else // ..else < 0 and its a negative.
        {
            $clock_moved *= -1;

            // Adding that timedifference to end-datetime should make parent::diff produce accurate results.
            $copy->add( DateInterval::createFromDateString("$clock_moved seconds") );
        }

        return parent::diff($copy, $absolute);
    } // <-- END "if ($offset_start != $offset_end)"

    return parent::diff($datetime, $absolute);
    }
}
?>

And a page for testing (will display results using both DateTime::diff and DateTimeDiff::diff):

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>DateTimeDiff-class</title>

<?php
if (! (empty($_GET['identifier']) && empty($_GET['start']) && empty($_GET['end'])))
{
    $dt1_new = new DateTimeDiff("{$_GET['start']} {$_GET['identifier']}");
    $dt1_old = new DateTime("{$_GET['start']} {$_GET['identifier']}");

    $dt2 = new DateTime("{$_GET['end']} {$_GET['identifier']}");

    $di_new = $dt1_new->diff($dt2);
    $di_old = $dt1_old->diff($dt2);


    // Extract UNIX timestamp and transitional data
    $timezone_start = $dt1_new->getTimezone();
    $timezone_end = $dt2->getTimezone();

    $timestamp_start = $dt1_new->getTimeStamp();
    $timestamp_end = $dt2->getTimeStamp();

    $transitions_start = $timezone_start->getTransitions($timestamp_start, $timestamp_start);
    $transitions_end = $timezone_end->getTransitions($timestamp_end, $timestamp_end);

    echo <<<BUILDCONTAINER

    <script type='text/javascript'>

        function Container() { }
        var c_new = new Container;
        var c_old = new Container;
        var t_start = new Container;
        var t_end = new Container;

    </script>

BUILDCONTAINER;

    echo <<<SETTRANSITIONS

    <script type='text/javascript'>

        t_start.ts = '{$transitions_start[0]['ts']}';
        t_start.time = '{$transitions_start[0]['time']}';
        t_start.offset = '{$transitions_start[0]['offset']}';

        t_end.ts = '{$transitions_end[0]['ts']}';
        t_end.time = '{$transitions_end[0]['time']}';
        t_end.offset = '{$transitions_end[0]['offset']}';

    </script>

SETTRANSITIONS;

    foreach ($di_new as $property => $value)
        echo "<script type='text/javascript'>c_new.$property = $value</script>";

    foreach ($di_old as $property => $value)
        echo "<script type='text/javascript'>c_old.$property = $value</script>";
}
?>

<script type='text/javascript'>

window.onload = function()
{
    if (c_new != null) // <-- em assume everything else is valid too.
    {
        // Update page with the results
        for (var prop in c_new)
            addtext(prop + ": " + c_new[prop] + " (" + c_old[prop] + ")");

        addtext("Read like so..");
        addtext("PROPERTY of DateInterval: VALUE using DateTimeDiff::diff  (  VALUE using DateTime::diff  )");

        // Restore values sent/recieved
        <?php

            foreach ($_GET as $key => $value)
                echo "document.getElementById('$key').value = '$value';";

        ?>

        // Display transitiondata (For DateTime start)
        var p_start = document.getElementById('p_start');
        var appendstring = "TS: " + t_start.ts + ", Time: " + t_start.time + ", Offset: " + t_start.offset;
        p_start.appendChild(document.createTextNode(appendstring));

        // Display transitiondata (For DateTime end)
        var p_end = document.getElementById('p_end');
        appendstring = "TS: " + t_end.ts + ", Time: " + t_end.time + ", Offset: " + t_end.offset;
        p_end.appendChild(document.createTextNode(appendstring));
    }
}

function addtext()
{
    var p = document.createElement("p");
    p.appendChild(document.createTextNode(arguments[0]));
    document.forms[0].appendChild(p);
}

</script>

</head>
<body>
<form action="test2.php" method="get">

    <p>Identifier: <input type="text" name="identifier" id="identifier" value="Europe/Stockholm" /></p>
    <p id="p_start">Start: <input type="text" name="start" id="start" /></p>
    <p id="p_end">End: <input type="text" name="end" id="end" /></p>
    <p><input type="submit" /></p>

</form>
</body>
</html>