diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 7cb5f7c1cf..5c13470f40 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -145,6 +145,7 @@ Yii Framework 2 Change Log - Enh #2646: Added support for specifying hostinfo in the pattern of a URL rule (qiangxue) - Enh #2661: Added boolean column type support for SQLite (qiangxue) - Enh #2670: Changed `console\Controller::globalOptions()` to `options($actionId)` to (make it possible to) differentiate options per action (hqx) +- Enh #2714: Added support for formatting time intervals relative to the current time with `yii\base\Formatter` (drenty) - Enh #2729: Added `FilterValidator::skipOnArray` so that filters like `trim` will not fail for array inputs (qiangxue) - Enh #2735: Added support for `DateTimeInterface` in `Formatter` (ivokund) - Enh #2756: Added support for injecting custom `isEmpty` check for all validators (qiangxue) diff --git a/framework/base/Formatter.php b/framework/base/Formatter.php index 0e1dd6b3c3..1cc22f4406 100644 --- a/framework/base/Formatter.php +++ b/framework/base/Formatter.php @@ -470,4 +470,93 @@ class Formatter extends Component return $verbose ? Yii::t('yii', '{n, plural, =1{# petabyte} other{# petabytes}}', $params) : Yii::t('yii', '{n} PB', $params); } } + + /** + * Formats the value as the time interval between a date and now in human readable form. + * @param integer|string|DateTime|DateInterval $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 into a UNIX timestamp via `strtotime()` or that can be passed to a DateInterval constructor. + * - a PHP DateTime object + * - a PHP DateInterval object (a positive time interval will refer to the past, a negative one to the future) + * + * @return string the formatted result + */ + public function asRelativeTime($value, $referenceTime = null) + { + if ($value === null) { + return $this->nullDisplay; + } + + if ($value instanceof \DateInterval) { + $interval = $value; + } else { + $timestamp = $this->normalizeDatetimeValue($value); + + if ($timestamp === false) { + // $value is not a valid date/time value, so we try + // to create a DateInterval with it + try { + $interval = new \DateInterval($value); + } catch (Exception $e) { + // invalid date/time and invalid interval + return $this->nullDisplay; + } + } else { + $timezone = new \DateTimeZone($this->timeZone); + + if ($referenceTime === null) { + $dateNow = new DateTime('now', $timezone); + } else { + $referenceTime = $this->normalizeDatetimeValue($referenceTime); + $dateNow = new DateTime(null, $timezone); + $dateNow->setTimestamp($referenceTime); + } + + $dateThen = new DateTime(null, $timezone); + $dateThen->setTimestamp($timestamp); + + $interval = $dateThen->diff($dateNow); + } + } + + if ($interval->invert) { + if ($interval->y >= 1) { + return Yii::t('yii', 'in {delta, plural, =1{a year} other{# years}}', ['delta' => $interval->y]); + } + if ($interval->m >= 1) { + return Yii::t('yii', 'in {delta, plural, =1{a month} other{# months}}', ['delta' => $interval->m]); + } + if ($interval->d >= 1) { + return Yii::t('yii', 'in {delta, plural, =1{a day} other{# days}}', ['delta' => $interval->d]); + } + if ($interval->h >= 1) { + return Yii::t('yii', 'in {delta, plural, =1{an hour} other{# hours}}', ['delta' => $interval->h]); + } + if ($interval->i >= 1) { + return Yii::t('yii', 'in {delta, plural, =1{a minute} other{# minutes}}', ['delta' => $interval->i]); + } + + return Yii::t('yii', 'in {delta, plural, =1{a second} other{# seconds}}', ['delta' => $interval->s]); + } else { + if ($interval->y >= 1) { + return Yii::t('yii', '{delta, plural, =1{a year} other{# years}} ago', ['delta' => $interval->y]); + } + if ($interval->m >= 1) { + return Yii::t('yii', '{delta, plural, =1{a month} other{# months}} ago', ['delta' => $interval->m]); + } + if ($interval->d >= 1) { + return Yii::t('yii', '{delta, plural, =1{a day} other{# days}} ago', ['delta' => $interval->d]); + } + if ($interval->h >= 1) { + return Yii::t('yii', '{delta, plural, =1{an hour} other{# hours}} ago', ['delta' => $interval->h]); + } + if ($interval->i >= 1) { + return Yii::t('yii', '{delta, plural, =1{a minute} other{# minutes}} ago', ['delta' => $interval->i]); + } + + return Yii::t('yii', '{delta, plural, =1{a second} other{# seconds}} ago', ['delta' => $interval->s]); + } + } } diff --git a/tests/unit/framework/base/FormatterTest.php b/tests/unit/framework/base/FormatterTest.php index a7e49b28f0..83c510fe99 100644 --- a/tests/unit/framework/base/FormatterTest.php +++ b/tests/unit/framework/base/FormatterTest.php @@ -8,6 +8,8 @@ namespace yiiunit\framework\base; use yii\base\Formatter; use yiiunit\TestCase; +use DateTime; +use DateInterval; /** * @group base @@ -197,4 +199,156 @@ class FormatterTest extends TestCase $this->setExpectedException('\yii\base\InvalidParamException'); $this->assertSame(date('Y-m-d', $value), $this->formatter->format($value, 'data')); } + + private function buildDateSubIntervals($referenceDate, $intervals) + { + $date = new DateTime($referenceDate); + foreach ($intervals as $interval) { + $date->sub($interval); + } + return $date; + } + + public function testAsRelativeTime() + { + $interval_1_second = new DateInterval("PT1S"); + $interval_244_seconds = new DateInterval("PT244S"); + $interval_1_minute = new DateInterval("PT1M"); + $interval_33_minutes = new DateInterval("PT33M"); + $interval_1_hour = new DateInterval("PT1H"); + $interval_6_hours = new DateInterval("PT6H"); + $interval_1_day = new DateInterval("P1D"); + $interval_89_days = new DateInterval("P89D"); + $interval_1_month = new DateInterval("P1M"); + $interval_5_months = new DateInterval("P5M"); + $interval_1_year = new DateInterval("P1Y"); + $interval_12_years = new DateInterval("P12Y"); + + // Pass a DateInterval + $this->assertSame('a second ago', $this->formatter->asRelativeTime($interval_1_second)); + $this->assertSame('244 seconds ago', $this->formatter->asRelativeTime($interval_244_seconds)); + $this->assertSame('a minute ago', $this->formatter->asRelativeTime($interval_1_minute)); + $this->assertSame('33 minutes ago', $this->formatter->asRelativeTime($interval_33_minutes)); + $this->assertSame('an hour ago', $this->formatter->asRelativeTime($interval_1_hour)); + $this->assertSame('6 hours ago', $this->formatter->asRelativeTime($interval_6_hours)); + $this->assertSame('a day ago', $this->formatter->asRelativeTime($interval_1_day)); + $this->assertSame('89 days ago', $this->formatter->asRelativeTime($interval_89_days)); + $this->assertSame('a month ago', $this->formatter->asRelativeTime($interval_1_month)); + $this->assertSame('5 months ago', $this->formatter->asRelativeTime($interval_5_months)); + $this->assertSame('a year ago', $this->formatter->asRelativeTime($interval_1_year)); + $this->assertSame('12 years ago', $this->formatter->asRelativeTime($interval_12_years)); + + // Pass a DateInterval string + $this->assertSame('a year ago', $this->formatter->asRelativeTime('2007-03-01T13:00:00Z/2008-05-11T15:30:00Z')); + $this->assertSame('a year ago', $this->formatter->asRelativeTime('2007-03-01T13:00:00Z/P1Y2M10DT2H30M')); + $this->assertSame('a year ago', $this->formatter->asRelativeTime('P1Y2M10DT2H30M/2008-05-11T15:30:00Z')); + $this->assertSame('a year ago', $this->formatter->asRelativeTime('P1Y2M10DT2H30M')); + $this->assertSame('94 months ago', $this->formatter->asRelativeTime('P94M')); + + // Force the reference time and pass a past DateTime + $dateNow = new DateTime('2014-03-13'); + $this->assertSame('a second ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_second]), $dateNow)); + $this->assertSame('4 minutes ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_244_seconds]), $dateNow)); + $this->assertSame('a minute ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_minute]), $dateNow)); + $this->assertSame('33 minutes ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_33_minutes]), $dateNow)); + $this->assertSame('an hour ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_hour]), $dateNow)); + $this->assertSame('6 hours ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_6_hours]), $dateNow)); + $this->assertSame('a day ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_day]), $dateNow)); + $this->assertSame('2 months ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_89_days]), $dateNow)); + $this->assertSame('a month ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_month]), $dateNow)); + $this->assertSame('5 months ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_5_months]), $dateNow)); + $this->assertSame('a year ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_year]), $dateNow)); + $this->assertSame('12 years ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_12_years]), $dateNow)); + + // Tricky 31-days month stuff + // See: http://www.gnu.org/software/tar/manual/html_section/Relative-items-in-date-strings.html + $dateNow = new DateTime('2014-03-31'); + $dateThen = new DateTime('2014-03-03'); + $this->assertSame('28 days ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-31', [$interval_1_month]), $dateNow)); + $this->assertSame('28 days ago', $this->formatter->asRelativeTime($dateThen, $dateNow)); + $dateThen = new DateTime('2014-02-28'); + $this->assertSame('a month ago', $this->formatter->asRelativeTime($dateThen, $dateNow)); + + // Relative to current time tests (can't test with seconds though due to the tests computation time) + $this->assertSame('4 minutes ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_244_seconds]))); + $this->assertSame('a minute ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_1_minute]))); + $this->assertSame('33 minutes ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_33_minutes]))); + $this->assertSame('an hour ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_1_hour]))); + $this->assertSame('6 hours ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_6_hours]))); + $this->assertSame('a day ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_1_day]))); + $this->assertSame('2 months ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_89_days]))); + $this->assertSame('a month ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_1_month]))); + $this->assertSame('5 months ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_5_months]))); + $this->assertSame('a year ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_1_year]))); + $this->assertSame('12 years ago', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_12_years]))); + + // Invert all the DateIntervals + $interval_1_second->invert = true; + $interval_244_seconds->invert = true; + $interval_1_minute->invert = true; + $interval_33_minutes->invert = true; + $interval_1_hour->invert = true; + $interval_6_hours->invert = true; + $interval_1_day->invert = true; + $interval_89_days->invert = true; + $interval_1_month->invert = true; + $interval_5_months->invert = true; + $interval_1_year->invert = true; + $interval_12_years->invert = true; + + // Pass a inverted DateInterval + $this->assertSame('in a second', $this->formatter->asRelativeTime($interval_1_second)); + $this->assertSame('in 244 seconds', $this->formatter->asRelativeTime($interval_244_seconds)); + $this->assertSame('in a minute', $this->formatter->asRelativeTime($interval_1_minute)); + $this->assertSame('in 33 minutes', $this->formatter->asRelativeTime($interval_33_minutes)); + $this->assertSame('in an hour', $this->formatter->asRelativeTime($interval_1_hour)); + $this->assertSame('in 6 hours', $this->formatter->asRelativeTime($interval_6_hours)); + $this->assertSame('in a day', $this->formatter->asRelativeTime($interval_1_day)); + $this->assertSame('in 89 days', $this->formatter->asRelativeTime($interval_89_days)); + $this->assertSame('in a month', $this->formatter->asRelativeTime($interval_1_month)); + $this->assertSame('in 5 months', $this->formatter->asRelativeTime($interval_5_months)); + $this->assertSame('in a year', $this->formatter->asRelativeTime($interval_1_year)); + $this->assertSame('in 12 years', $this->formatter->asRelativeTime($interval_12_years)); + + // Pass a inverted DateInterval string + $this->assertSame('in a year', $this->formatter->asRelativeTime('2008-05-11T15:30:00Z/2007-03-01T13:00:00Z')); + + // Force the reference time and pass a future DateTime + $dateNow = new DateTime('2014-03-13'); + $this->assertSame('in a second', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_second]), $dateNow)); + $this->assertSame('in 4 minutes', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_244_seconds]), $dateNow)); + $this->assertSame('in a minute', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_minute]), $dateNow)); + $this->assertSame('in 33 minutes', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_33_minutes]), $dateNow)); + $this->assertSame('in an hour', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_hour]), $dateNow)); + $this->assertSame('in 6 hours', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_6_hours]), $dateNow)); + $this->assertSame('in a day', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_day]), $dateNow)); + $this->assertSame('in 2 months', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_89_days]), $dateNow)); + $this->assertSame('in a month', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_month]), $dateNow)); + $this->assertSame('in 5 months', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_5_months]), $dateNow)); + $this->assertSame('in a year', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_1_year]), $dateNow)); + $this->assertSame('in 12 years', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-13', [$interval_12_years]), $dateNow)); + + // Tricky 31-days month stuff + // See: http://www.gnu.org/software/tar/manual/html_section/Relative-items-in-date-strings.html + $dateNow = new DateTime('2014-03-03'); + $dateThen = new DateTime('2014-03-31'); + $this->assertSame('in a month', $this->formatter->asRelativeTime($this->buildDateSubIntervals('2014-03-03', [$interval_1_month]), $dateNow)); + $this->assertSame('in 28 days', $this->formatter->asRelativeTime($dateThen, $dateNow)); + + // Relative to current time tests (can't test with seconds though due to the tests computation time) + // We add 5 seconds to compensate for tests computation time + $interval_5_seconds = new DateInterval('PT5S'); + $interval_5_seconds->invert = true; + $this->assertSame('in 4 minutes', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_244_seconds, $interval_5_seconds]))); + $this->assertSame('in a minute', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_1_minute, $interval_5_seconds]))); + $this->assertSame('in 33 minutes', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_33_minutes, $interval_5_seconds]))); + $this->assertSame('in an hour', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_1_hour, $interval_5_seconds]))); + $this->assertSame('in 6 hours', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_6_hours, $interval_5_seconds]))); + $this->assertSame('in a day', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_1_day, $interval_5_seconds]))); + $this->assertSame('in 2 months', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_89_days, $interval_5_seconds]))); + $this->assertSame('in a month', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_1_month, $interval_5_seconds]))); + $this->assertSame('in 5 months', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_5_months, $interval_5_seconds]))); + $this->assertSame('in a year', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_1_year, $interval_5_seconds]))); + $this->assertSame('in 12 years', $this->formatter->asRelativeTime($this->buildDateSubIntervals('now', [$interval_12_years, $interval_5_seconds]))); + } }