diff --git a/src/I18n/Date.php b/src/I18n/Date.php index 0b486a32ca7..9fe9bbad069 100644 --- a/src/I18n/Date.php +++ b/src/I18n/Date.php @@ -68,4 +68,227 @@ class Date extends BaseDate implements JsonSerializable * @see \Cake\I18n\DateFormatTrait::nice() */ public static $niceFormat = [IntlDateFormatter::MEDIUM, -1]; + + /** + * The format to use when formatting a time using `Time::timeAgoInWords()` + * and the difference is less than `Time::$wordEnd` + * + * @var array + * @see \Cake\I18n\Date::timeAgoInWords() + */ + public static $wordAccuracy = [ + 'year' => "day", + 'month' => "day", + 'week' => "day", + 'day' => "day", + 'hour' => "day", + 'minute' => "day", + 'second' => "day", + ]; + + /** + * The end of relative time telling + * + * @var string + * @see \Cake\I18n\Date::timeAgoInWords() + */ + public static $wordEnd = '+1 month'; + + /** + * Returns either a relative or a formatted absolute date depending + * on the difference between the current date and this object. + * + * ### Options: + * + * - `from` => another Date object representing the "now" date + * - `format` => a fall back format if the relative time is longer than the duration specified by end + * - `accuracy` => Specifies how accurate the date should be described (array) + * - year => The format if years > 0 (default "day") + * - month => The format if months > 0 (default "day") + * - week => The format if weeks > 0 (default "day") + * - day => The format if weeks > 0 (default "day") + * - `end` => The end of relative date telling + * - `relativeString` => The printf compatible string when outputting relative date + * - `absoluteString` => The printf compatible string when outputting absolute date + * - `timezone` => The user timezone the timestamp should be formatted in. + * + * Relative dates look something like this: + * + * - 3 weeks, 4 days ago + * - 1 day ago + * + * Default date formatting is d/M/YY e.g: on 18/2/09. Formatting is done internally using + * `i18nFormat`, see the method for the valid formatting strings. + * + * The returned string includes 'ago' or 'on' and assumes you'll properly add a word + * like 'Posted ' before the function output. + * + * NOTE: If the difference is one week or more, the lowest level of accuracy is day. + * + * @param array $options Array of options. + * @return string Relative time string. + */ + public function timeAgoInWords(array $options = []) + { + $date = $this; + + $options += [ + 'from' => static::now(), + 'timezone' => null, + 'format' => static::$wordFormat, + 'accuracy' => static::$wordAccuracy, + 'end' => static::$wordEnd, + 'relativeString' => __d('cake', '%s ago'), + 'absoluteString' => __d('cake', 'on %s'), + ]; + if (is_string($options['accuracy'])) { + foreach (static::$wordAccuracy as $key => $level) { + $options[$key] = $options['accuracy']; + } + } else { + $options['accuracy'] += static::$wordAccuracy; + } + if ($options['timezone']) { + $date = $date->timezone($options['timezone']); + } + + $now = $options['from']->format('U'); + $inSeconds = $date->format('U'); + $backwards = ($inSeconds > $now); + + $futureTime = $now; + $pastTime = $inSeconds; + if ($backwards) { + $futureTime = $inSeconds; + $pastTime = $now; + } + $diff = $futureTime - $pastTime; + + if (!$diff) { + return __d('cake', 'today'); + } + + if ($diff > abs($now - (new static($options['end']))->format('U'))) { + return sprintf($options['absoluteString'], $date->i18nFormat($options['format'])); + } + + // If more than a week, then take into account the length of months + if ($diff >= 604800) { + list($future['H'], $future['i'], $future['s'], $future['d'], $future['m'], $future['Y']) = explode('/', date('H/i/s/d/m/Y', $futureTime)); + + list($past['H'], $past['i'], $past['s'], $past['d'], $past['m'], $past['Y']) = explode('/', date('H/i/s/d/m/Y', $pastTime)); + $weeks = $days = $hours = $minutes = $seconds = 0; + + $years = $future['Y'] - $past['Y']; + $months = $future['m'] + ((12 * $years) - $past['m']); + + if ($months >= 12) { + $years = floor($months / 12); + $months = $months - ($years * 12); + } + if ($future['m'] < $past['m'] && $future['Y'] - $past['Y'] === 1) { + $years--; + } + + if ($future['d'] >= $past['d']) { + $days = $future['d'] - $past['d']; + } else { + $daysInPastMonth = date('t', $pastTime); + $daysInFutureMonth = date('t', mktime(0, 0, 0, $future['m'] - 1, 1, $future['Y'])); + + if (!$backwards) { + $days = ($daysInPastMonth - $past['d']) + $future['d']; + } else { + $days = ($daysInFutureMonth - $past['d']) + $future['d']; + } + + if ($future['m'] != $past['m']) { + $months--; + } + } + + if (!$months && $years >= 1 && $diff < ($years * 31536000)) { + $months = 11; + $years--; + } + + if ($months >= 12) { + $years = $years + 1; + $months = $months - 12; + } + + if ($days >= 7) { + $weeks = floor($days / 7); + $days = $days - ($weeks * 7); + } + } else { + $years = $months = $weeks = 0; + $days = floor($diff / 86400); + + $diff = $diff - ($days * 86400); + + $hours = floor($diff / 3600); + $diff = $diff - ($hours * 3600); + + $minutes = floor($diff / 60); + $diff = $diff - ($minutes * 60); + $seconds = $diff; + } + + $fWord = $options['accuracy']['day']; + if ($years > 0) { + $fWord = $options['accuracy']['year']; + } elseif (abs($months) > 0) { + $fWord = $options['accuracy']['month']; + } elseif (abs($weeks) > 0) { + $fWord = $options['accuracy']['week']; + } elseif (abs($days) > 0) { + $fWord = $options['accuracy']['day']; + } + + $fNum = str_replace(['year', 'month', 'week', 'day'], [1, 2, 3, 4], $fWord); + + $relativeDate = ''; + if ($fNum >= 1 && $years > 0) { + $relativeDate .= ($relativeDate ? ', ' : '') . __dn('cake', '{0} year', '{0} years', $years, $years); + } + if ($fNum >= 2 && $months > 0) { + $relativeDate .= ($relativeDate ? ', ' : '') . __dn('cake', '{0} month', '{0} months', $months, $months); + } + if ($fNum >= 3 && $weeks > 0) { + $relativeDate .= ($relativeDate ? ', ' : '') . __dn('cake', '{0} week', '{0} weeks', $weeks, $weeks); + } + if ($fNum >= 4 && $days > 0) { + $relativeDate .= ($relativeDate ? ', ' : '') . __dn('cake', '{0} day', '{0} days', $days, $days); + } + + // When time has passed + if (!$backwards && $relativeDate) { + return sprintf($options['relativeString'], $relativeDate); + } + if (!$backwards) { + $aboutAgo = [ + 'day' => __d('cake', 'about a day ago'), + 'week' => __d('cake', 'about a week ago'), + 'month' => __d('cake', 'about a month ago'), + 'year' => __d('cake', 'about a year ago') + ]; + + return $aboutAgo[$fWord]; + } + + // When time is to come + if (!$relativeDate) { + $aboutIn = [ + 'day' => __d('cake', 'in about a day'), + 'week' => __d('cake', 'in about a week'), + 'month' => __d('cake', 'in about a month'), + 'year' => __d('cake', 'in about a year') + ]; + + return $aboutIn[$fWord]; + } + + return $relativeDate; + } } diff --git a/tests/TestCase/I18n/DateTest.php b/tests/TestCase/I18n/DateTest.php index 9fe0c699166..4f26093ba49 100644 --- a/tests/TestCase/I18n/DateTest.php +++ b/tests/TestCase/I18n/DateTest.php @@ -152,4 +152,278 @@ public function testParseDateTime() $date = Date::parseDate('13 10, 2015 12:54:12'); $this->assertEquals('2015-10-13 00:00:00', $date->format('Y-m-d H:i:s')); } + + /** + * provider for timeAgoInWords() tests + * + * @return array + */ + public static function timeAgoProvider() + { + return [ + ['-12 seconds', 'today'], + ['-12 minutes', 'today'], + ['-2 hours', 'today'], + ['-1 day', '1 day ago'], + ['-2 days', '2 days ago'], + ['-1 week', '1 week ago'], + ['-2 weeks -2 days', '2 weeks, 2 days ago'], + ['+1 second', 'today'], + ['+1 minute, +10 seconds', 'today'], + ['+1 week', '1 week'], + ['+1 week 1 day', '1 week, 1 day'], + ['+2 weeks 2 day', '2 weeks, 2 days'], + ['2007-9-24', 'on 9/24/07'], + ['now', 'today'], + ]; + } + + /** + * testTimeAgoInWords method + * + * @dataProvider timeAgoProvider + * @return void + */ + public function testTimeAgoInWords($input, $expected) + { + $date = new Date($input); + $result = $date->timeAgoInWords(); + $this->assertEquals($expected, $result); + } + + /** + * test the timezone option for timeAgoInWords + * + * @return void + */ + public function testTimeAgoInWordsTimezone() + { + $date = new Date('1990-07-31 20:33:00 UTC'); + $result = $date->timeAgoInWords( + [ + 'timezone' => 'America/Vancouver', + 'end' => '+1month', + 'format' => 'dd-MM-YYYY' + ] + ); + $this->assertEquals('on 31-07-1990', $result); + } + + /** + * provider for timeAgo with an end date. + * + * @return void + */ + public function timeAgoEndProvider() + { + return [ + [ + '+4 months +2 weeks +3 days', + '4 months, 2 weeks, 3 days', + '8 years' + ], + [ + '+4 months +2 weeks +1 day', + '4 months, 2 weeks, 1 day', + '8 years' + ], + [ + '+3 months +2 weeks', + '3 months, 2 weeks', + '8 years' + ], + [ + '+3 months +2 weeks +1 day', + '3 months, 2 weeks, 1 day', + '8 years' + ], + [ + '+1 months +1 week +1 day', + '1 month, 1 week, 1 day', + '8 years' + ], + [ + '+2 months +2 days', + '2 months, 2 days', + '+2 months +2 days' + ], + [ + '+2 months +12 days', + '2 months, 1 week, 5 days', + '3 months' + ], + ]; + } + + /** + * test the end option for timeAgoInWords + * + * @dataProvider timeAgoEndProvider + * @return void + */ + public function testTimeAgoInWordsEnd($input, $expected, $end) + { + $time = new Date($input); + $result = $time->timeAgoInWords(['end' => $end]); + $this->assertEquals($expected, $result); + } + + /** + * test the custom string options for timeAgoInWords + * + * @return void + */ + public function testTimeAgoInWordsCustomStrings() + { + $date = new Date('-8 years -4 months -2 weeks -3 days'); + $result = $date->timeAgoInWords([ + 'relativeString' => 'at least %s ago', + 'accuracy' => ['year' => 'year'], + 'end' => '+10 years' + ]); + $expected = 'at least 8 years ago'; + $this->assertEquals($expected, $result); + + $date = new Date('+4 months +2 weeks +3 days'); + $result = $date->timeAgoInWords([ + 'absoluteString' => 'exactly on %s', + 'accuracy' => ['year' => 'year'], + 'end' => '+2 months' + ]); + $expected = 'exactly on ' . date('n/j/y', strtotime('+4 months +2 weeks +3 days')); + $this->assertEquals($expected, $result); + } + + /** + * Test the accuracy option for timeAgoInWords() + * + * @return void + */ + public function testDateAgoInWordsAccuracy() + { + $date = new Date('+8 years +4 months +2 weeks +3 days'); + $result = $date->timeAgoInWords([ + 'accuracy' => ['year' => 'year'], + 'end' => '+10 years' + ]); + $expected = '8 years'; + $this->assertEquals($expected, $result); + + $date = new Date('+8 years +4 months +2 weeks +3 days'); + $result = $date->timeAgoInWords([ + 'accuracy' => ['year' => 'month'], + 'end' => '+10 years' + ]); + $expected = '8 years, 4 months'; + $this->assertEquals($expected, $result); + + $date = new Date('+8 years +4 months +2 weeks +3 days'); + $result = $date->timeAgoInWords([ + 'accuracy' => ['year' => 'week'], + 'end' => '+10 years' + ]); + $expected = '8 years, 4 months, 2 weeks'; + $this->assertEquals($expected, $result); + + $date = new Date('+8 years +4 months +2 weeks +3 days'); + $result = $date->timeAgoInWords([ + 'accuracy' => ['year' => 'day'], + 'end' => '+10 years' + ]); + $expected = '8 years, 4 months, 2 weeks, 3 days'; + $this->assertEquals($expected, $result); + + $date = new Date('+1 years +5 weeks'); + $result = $date->timeAgoInWords([ + 'accuracy' => ['year' => 'year'], + 'end' => '+10 years' + ]); + $expected = '1 year'; + $this->assertEquals($expected, $result); + + $date = new Date('+23 hours'); + $result = $date->timeAgoInWords([ + 'accuracy' => 'day' + ]); + $expected = 'today'; + $this->assertEquals($expected, $result); + } + + /** + * Test the format option of timeAgoInWords() + * + * @return void + */ + public function testDateAgoInWordsWithFormat() + { + $date = new Date('2007-9-25'); + $result = $date->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertEquals('on 2007-09-25', $result); + + $date = new Date('2007-9-25'); + $result = $date->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertEquals('on 2007-09-25', $result); + + $date = new Date('+2 weeks +2 days'); + $result = $date->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertRegExp('/^2 weeks, [1|2] day(s)?$/', $result); + + $date = new Date('+2 months +2 days'); + $result = $date->timeAgoInWords(['end' => '1 month', 'format' => 'yyyy-MM-dd']); + $this->assertEquals('on ' . date('Y-m-d', strtotime('+2 months +2 days')), $result); + } + + /** + * test timeAgoInWords() with negative values. + * + * @return void + */ + public function testDateAgoInWordsNegativeValues() + { + $date = new Date('-2 months -2 days'); + $result = $date->timeAgoInWords(['end' => '3 month']); + $this->assertEquals('2 months, 2 days ago', $result); + + $date = new Date('-2 months -2 days'); + $result = $date->timeAgoInWords(['end' => '3 month']); + $this->assertEquals('2 months, 2 days ago', $result); + + $date = new Date('-2 months -2 days'); + $result = $date->timeAgoInWords(['end' => '1 month', 'format' => 'yyyy-MM-dd']); + $this->assertEquals('on ' . date('Y-m-d', strtotime('-2 months -2 days')), $result); + + $date = new Date('-2 years -5 months -2 days'); + $result = $date->timeAgoInWords(['end' => '3 years']); + $this->assertEquals('2 years, 5 months, 2 days ago', $result); + + $date = new Date('-2 weeks -2 days'); + $result = $date->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertEquals('2 weeks, 2 days ago', $result); + + $date = new Date('-3 years -12 months'); + $result = $date->timeAgoInWords(); + $expected = 'on ' . $date->format('n/j/y'); + $this->assertEquals($expected, $result); + + $date = new Date('-1 month -1 week -6 days'); + $result = $date->timeAgoInWords( + ['end' => '1 year', 'accuracy' => ['month' => 'month']] + ); + $this->assertEquals('1 month ago', $result); + + $date = new Date('-1 years -2 weeks -3 days'); + $result = $date->timeAgoInWords( + ['accuracy' => ['year' => 'year']] + ); + $expected = 'on ' . $date->format('n/j/y'); + $this->assertEquals($expected, $result); + + $date = new Date('-13 months -5 days'); + $result = $date->timeAgoInWords(['end' => '2 years']); + $this->assertEquals('1 year, 1 month, 5 days ago', $result); + + $date = new Date('-23 hours'); + $result = $date->timeAgoInWords(['accuracy' => 'day']); + $this->assertEquals('today', $result); + } }