From 6267b9ee1adb637f1bda0b7c2ec898f47d94da79 Mon Sep 17 00:00:00 2001
From: Carsten Brandt <mail@cebe.cc>
Date: Tue, 30 Sep 2014 02:15:45 +0200
Subject: [PATCH] Fixed issue with timezone conversion in formatter

related to #5128
---
 docs/guide/output-formatter.md              |  2 ++
 framework/CHANGELOG.md                      |  2 ++
 framework/UPGRADE.md                        |  6 ++++++
 framework/i18n/Formatter.php                | 55 ++++++++++++++++++++++++++++++++++++++-----------------
 tests/unit/framework/i18n/FormatterTest.php | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 132 insertions(+), 17 deletions(-)

diff --git a/docs/guide/output-formatter.md b/docs/guide/output-formatter.md
index 485fe52..8939f10 100644
--- a/docs/guide/output-formatter.md
+++ b/docs/guide/output-formatter.md
@@ -88,6 +88,8 @@ See http://site.icu-project.org/ for the format.
   and now in human readable form.
 
 
+The input value for date and time formatting is assumed to be in UTC unless a timezone is explicitly given.
+
 Formatting Numbers
 ------------------
 
diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md
index cfdd605..eabd0fd 100644
--- a/framework/CHANGELOG.md
+++ b/framework/CHANGELOG.md
@@ -4,6 +4,7 @@ Yii Framework 2 Change Log
 2.0.0 under development
 -----------------------
 
+- Bug: Date and time formatting now assumes UTC as the timezone for input dates unless a timezone is explicitly given (cebe)
 - Enh #4275: Added `removeChildren()` to `yii\rbac\ManagerInterface` and implementations (samdark)
 
 
@@ -623,6 +624,7 @@ Yii Framework 2 Change Log
 - New: Added various authentication methods, including `HttpBasicAuth`, `HttpBearerAuth`, `QueryParamAuth`, and `CompositeAuth` (qiangxue)
 - New: Added `HtmlResponseFormatter` and `JsonResponseFormatter` (qiangxue)
 
+
 2.0.0-alpha, December 1, 2013
 -----------------------------
 
diff --git a/framework/UPGRADE.md b/framework/UPGRADE.md
index bfb0f65..66028cd 100644
--- a/framework/UPGRADE.md
+++ b/framework/UPGRADE.md
@@ -13,6 +13,12 @@ Upgrade from Yii 2.0 RC
 
 * If you've implemented `yii\rbac\ManagerInterface` you need to add implementation for new method `removeChildren()`.
 
+* The input dates for datetime formatting are now assumed to be in UTC unless a timezone is explicitly given.
+  Before, the timezone assumed for input dates was the default timezone set by PHP which is the same as `Yii::$app->timeZone`.
+  This causes trouble because the formatter uses `Yii::$app->timeZone` as the default values for output so no timezone conversion
+  was possible. If your timestamps are stored in the database without a timezone identifier you have to ensure they are in UTC or
+  add a timezone identifier explicitly.
+
 
 Upgrade from Yii 2.0 Beta
 -------------------------
diff --git a/framework/i18n/Formatter.php b/framework/i18n/Formatter.php
index 7283a7c..3837ecc 100644
--- a/framework/i18n/Formatter.php
+++ b/framework/i18n/Formatter.php
@@ -8,6 +8,7 @@
 namespace yii\i18n;
 
 use DateTime;
+use DateTimeZone;
 use IntlDateFormatter;
 use NumberFormatter;
 use Yii;
@@ -66,6 +67,9 @@ class Formatter extends Component
      * e.g. `UTC`, `Europe/Berlin` or `America/Chicago`.
      * Refer to the [php manual](http://www.php.net/manual/en/timezones.php) for available timezones.
      * If this property is not set, [[\yii\base\Application::timeZone]] will be used.
+     *
+     * Note that the input timezone is assumed to be UTC always if no timezone is included in the input date value.
+     * Make sure to store datetime values in UTC in your database.
      */
     public $timeZone;
     /**
@@ -387,8 +391,9 @@ class Formatter extends Component
      * types of value are supported:
      *
      * - an integer representing a UNIX timestamp
-     * - a string that can be parsed into a UNIX timestamp via `strtotime()`
-     * - a PHP DateTime object
+     * - a string that can be [parsed to create a DateTime object](http://php.net/manual/en/datetime.formats.php).
+     *   The timestamp is assumed to be in UTC unless a timezone is explicitly given.
+     * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object
      *
      * @param string $format the format used to convert the value into a date string.
      * If null, [[dateFormat]] will be used.
@@ -399,9 +404,9 @@ class Formatter extends Component
      * Alternatively this can be a string prefixed with `php:` representing a format that can be recognized by the
      * PHP [date()](http://php.net/manual/de/function.date.php)-function.
      *
+     * @return string the formatted result.
      * @throws InvalidParamException if the input value can not be evaluated as a date value.
      * @throws InvalidConfigException if the date format is invalid.
-     * @return string the formatted result.
      * @see dateFormat
      */
     public function asDate($value, $format = null)
@@ -418,8 +423,9 @@ class Formatter extends Component
      * types of value are supported:
      *
      * - an integer representing a UNIX timestamp
-     * - a string that can be parsed into a UNIX timestamp via `strtotime()`
-     * - a PHP DateTime object
+     * - a string that can be [parsed to create a DateTime object](http://php.net/manual/en/datetime.formats.php).
+     *   The timestamp is assumed to be in UTC unless a timezone is explicitly given.
+     * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object
      *
      * @param string $format the format used to convert the value into a date string.
      * If null, [[timeFormat]] will be used.
@@ -430,9 +436,9 @@ class Formatter extends Component
      * Alternatively this can be a string prefixed with `php:` representing a format that can be recognized by the
      * PHP [date()](http://php.net/manual/de/function.date.php)-function.
      *
+     * @return string the formatted result.
      * @throws InvalidParamException if the input value can not be evaluated as a date value.
      * @throws InvalidConfigException if the date format is invalid.
-     * @return string the formatted result.
      * @see timeFormat
      */
     public function asTime($value, $format = null)
@@ -449,8 +455,9 @@ class Formatter extends Component
      * types of value are supported:
      *
      * - an integer representing a UNIX timestamp
-     * - a string that can be parsed into a UNIX timestamp via `strtotime()`
-     * - a PHP DateTime object
+     * - a string that can be [parsed to create a DateTime object](http://php.net/manual/en/datetime.formats.php).
+     *   The timestamp is assumed to be in UTC unless a timezone is explicitly given.
+     * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object
      *
      * @param string $format the format used to convert the value into a date string.
      * If null, [[dateFormat]] will be used.
@@ -461,9 +468,9 @@ class Formatter extends Component
      * Alternatively this can be a string prefixed with `php:` representing a format that can be recognized by the
      * PHP [date()](http://php.net/manual/de/function.date.php)-function.
      *
+     * @return string the formatted result.
      * @throws InvalidParamException if the input value can not be evaluated as a date value.
      * @throws InvalidConfigException if the date format is invalid.
-     * @return string the formatted result.
      * @see datetimeFormat
      */
     public function asDatetime($value, $format = null)
@@ -485,7 +492,14 @@ class Formatter extends Component
     ];
 
     /**
-     * @param integer $value normalized datetime value
+     * @param integer|string|DateTime $value the value to be formatted. The following
+     * types of value are supported:
+     *
+     * - an integer representing a UNIX timestamp
+     * - a string that can be [parsed to create a DateTime object](http://php.net/manual/en/datetime.formats.php).
+     *   The timestamp is assumed to be in UTC unless a timezone is explicitly given.
+     * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object
+     *
      * @param string $format the format used to convert the value into a date string.
      * @param string $type 'date', 'time', or 'datetime'.
      * @throws InvalidConfigException if the date format is invalid.
@@ -524,7 +538,7 @@ class Formatter extends Component
                 $format = FormatConverter::convertDateIcuToPhp($format, $type, $this->locale);
             }
             if ($this->timeZone != null) {
-                $timestamp->setTimezone(new \DateTimeZone($this->timeZone));
+                $timestamp->setTimezone(new DateTimeZone($this->timeZone));
             }
             return $timestamp->format($format);
         }
@@ -533,7 +547,14 @@ class Formatter extends Component
     /**
      * Normalizes the given datetime value as a DateTime object that can be taken by various date/time formatting methods.
      *
-     * @param mixed $value the datetime value to be normalized.
+     * @param integer|string|DateTime $value the datetime value to be normalized. The following
+     * types of value are supported:
+     *
+     * - an integer representing a UNIX timestamp
+     * - a string that can be [parsed to create a DateTime object](http://php.net/manual/en/datetime.formats.php).
+     *   The timestamp is assumed to be in UTC unless a timezone is explicitly given.
+     * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object
+     *
      * @return DateTime the normalized datetime value
      * @throws InvalidParamException if the input value can not be evaluated as a date value.
      */
@@ -548,17 +569,17 @@ class Formatter extends Component
         }
         try {
             if (is_numeric($value)) { // process as unix timestamp
-                if (($timestamp = DateTime::createFromFormat('U', $value)) === false) {
+                if (($timestamp = DateTime::createFromFormat('U', $value, new DateTimeZone('UTC'))) === false) {
                     throw new InvalidParamException("Failed to parse '$value' as a UNIX timestamp.");
                 }
                 return $timestamp;
-            } elseif (($timestamp = DateTime::createFromFormat('Y-m-d', $value)) !== false) { // try Y-m-d format
+            } elseif (($timestamp = DateTime::createFromFormat('Y-m-d', $value, new DateTimeZone('UTC'))) !== false) { // try Y-m-d format (support invalid dates like 2012-13-01)
                 return $timestamp;
-            } elseif (($timestamp = DateTime::createFromFormat('Y-m-d H:i:s', $value)) !== false) { // try Y-m-d H:i:s format
+            } elseif (($timestamp = DateTime::createFromFormat('Y-m-d H:i:s', $value, new DateTimeZone('UTC'))) !== false) { // try Y-m-d H:i:s format (support invalid dates like 2012-13-01 12:63:12)
                 return $timestamp;
             }
             // finally try to create a DateTime object with the value
-            $timestamp = new DateTime($value);
+            $timestamp = new DateTime($value, new DateTimeZone('UTC'));
             return $timestamp;
         } catch(\Exception $e) {
             throw new InvalidParamException("'$value' is not a valid date time value: " . $e->getMessage()
@@ -623,7 +644,7 @@ class Formatter extends Component
                     return $this->nullDisplay;
                 }
             } else {
-                $timezone = new \DateTimeZone($this->timeZone);
+                $timezone = new DateTimeZone($this->timeZone);
 
                 if ($referenceTime === null) {
                     $dateNow = new DateTime('now', $timezone);
diff --git a/tests/unit/framework/i18n/FormatterTest.php b/tests/unit/framework/i18n/FormatterTest.php
index 1cf26ab..81372f1 100644
--- a/tests/unit/framework/i18n/FormatterTest.php
+++ b/tests/unit/framework/i18n/FormatterTest.php
@@ -483,6 +483,90 @@ class FormatterTest extends TestCase
     }
 
 
+    public function provideTimezones()
+    {
+        return [
+            ['UTC'],
+            ['Europe/Berlin'],
+            ['America/Jamaica'],
+        ];
+    }
+
+    /**
+     * provide default timezones times input date value
+     */
+    public function provideTimesAndTz()
+    {
+        $result = [];
+        foreach($this->provideTimezones() as $tz) {
+            $result[] = [$tz[0], 1407674460,                          1388580060];
+            $result[] = [$tz[0], '2014-08-10 12:41:00',               '2014-01-01 12:41:00'];
+            $result[] = [$tz[0], '2014-08-10 12:41:00 UTC',           '2014-01-01 12:41:00 UTC'];
+            $result[] = [$tz[0], '2014-08-10 14:41:00 Europe/Berlin', '2014-01-01 13:41:00 Europe/Berlin'];
+            $result[] = [$tz[0], '2014-08-10 14:41:00 CEST',          '2014-01-01 13:41:00 CET'];
+            $result[] = [$tz[0], '2014-08-10 14:41:00+0200',          '2014-01-01 13:41:00+0100'];
+            $result[] = [$tz[0], '2014-08-10 14:41:00+02:00',         '2014-01-01 13:41:00+01:00'];
+            $result[] = [$tz[0], '2014-08-10 14:41:00 +0200',         '2014-01-01 13:41:00 +0100'];
+            $result[] = [$tz[0], '2014-08-10 14:41:00 +02:00',        '2014-01-01 13:41:00 +01:00'];
+            $result[] = [$tz[0], '2014-08-10T14:41:00+02:00',         '2014-01-01T13:41:00+01:00']; // ISO 8601
+        }
+        return $result;
+    }
+
+    /**
+     * Test timezones with input date and time in other timezones
+     * @dataProvider provideTimesAndTz
+     */
+    public function testIntlTimezoneInput($defaultTz, $inputTimeDst, $inputTimeNonDst)
+    {
+        $this->testTimezoneInput($defaultTz, $inputTimeDst, $inputTimeNonDst);
+    }
+
+    /**
+     * Test timezones with input date and time in other timezones
+     * @dataProvider provideTimesAndTz
+     */
+    public function testTimezoneInput($defaultTz, $inputTimeDst, $inputTimeNonDst)
+    {
+        date_default_timezone_set($defaultTz); // formatting has to be independent of the default timezone set by PHP
+        $this->formatter->datetimeFormat = 'yyyy-MM-dd HH:mm:ss';
+        $this->formatter->dateFormat = 'yyyy-MM-dd';
+        $this->formatter->timeFormat = 'HH:mm:ss';
+
+        // daylight saving time
+        $this->formatter->timeZone = 'UTC';
+        $this->assertSame('2014-08-10 12:41:00', $this->formatter->asDatetime($inputTimeDst));
+        $this->assertSame('2014-08-10', $this->formatter->asDate($inputTimeDst));
+        $this->assertSame('12:41:00', $this->formatter->asTime($inputTimeDst));
+        $this->assertSame('1407674460', $this->formatter->asTimestamp($inputTimeDst));
+        $this->formatter->timeZone = 'Europe/Berlin';
+        $this->assertSame('2014-08-10 14:41:00', $this->formatter->asDatetime($inputTimeDst));
+        $this->assertSame('2014-08-10', $this->formatter->asDate($inputTimeDst));
+        $this->assertSame('14:41:00', $this->formatter->asTime($inputTimeDst));
+        $this->assertSame('1407674460', $this->formatter->asTimestamp($inputTimeDst));
+
+        // non daylight saving time
+        $this->formatter->timeZone = 'UTC';
+        $this->assertSame('2014-01-01 12:41:00', $this->formatter->asDatetime($inputTimeNonDst));
+        $this->assertSame('2014-01-01', $this->formatter->asDate($inputTimeNonDst));
+        $this->assertSame('12:41:00', $this->formatter->asTime($inputTimeNonDst));
+        $this->assertSame('1388580060', $this->formatter->asTimestamp($inputTimeNonDst));
+        $this->formatter->timeZone = 'Europe/Berlin';
+        $this->assertSame('2014-01-01 13:41:00', $this->formatter->asDatetime($inputTimeNonDst));
+        $this->assertSame('2014-01-01', $this->formatter->asDate($inputTimeNonDst));
+        $this->assertSame('13:41:00', $this->formatter->asTime($inputTimeNonDst));
+        $this->assertSame('1388580060', $this->formatter->asTimestamp($inputTimeNonDst));
+
+        // tests for relative time
+        if ($inputTimeDst !== 1407674460) {
+            $this->assertSame('3 hours ago', $this->formatter->asRelativeTime($inputTimeDst, $relativeTime = str_replace(['14:41', '12:41'], ['17:41', '15:41'], $inputTimeDst)));
+            $this->assertSame('in 3 hours', $this->formatter->asRelativeTime($relativeTime, $inputTimeDst));
+            $this->assertSame('3 hours ago', $this->formatter->asRelativeTime($inputTimeNonDst, $relativeTime = str_replace(['13:41', '12:41'], ['16:41', '15:41'], $inputTimeNonDst)));
+            $this->assertSame('in 3 hours', $this->formatter->asRelativeTime($relativeTime, $inputTimeNonDst));
+        }
+    }
+
+
     // number format
 
 
--
libgit2 0.27.1