diff --git a/src/Contracts/SomoyInterface.php b/src/Contracts/SomoyInterface.php index 781b306..3b7575e 100644 --- a/src/Contracts/SomoyInterface.php +++ b/src/Contracts/SomoyInterface.php @@ -735,6 +735,35 @@ public function to_json(); */ public function to_sql_datetime_string(); + /** + * Get a human-readable difference between the instance and another date. + * + * Modeled on Carbon's diffForHumans(). The DIFF_* syntax constants and + * the NO_ZERO_DIFF / JUST_NOW / ONE_DAY_WORDS / TWO_DAY_WORDS / + * SEQUENTIAL_PARTS_ONLY option constants are declared on the concrete + * Somoy class rather than here, for the same reason the format constants + * above are: implementations must be able to override them, which PHP + * 7.4 forbids for a constant inherited from an interface. + * + * @param DateTimeInterface|string|int|float|null $other The date to + * compare with. Defaults to now. + * @param int|null $syntax One of the DIFF_* constants. Defaults to + * DIFF_RELATIVE_TO_NOW. + * @param bool $short Use abbreviated unit names, e.g. "5min" instead of + * "5 minutes". + * @param int $parts How many of the largest non-zero units to include, + * clamped between 1 and 7. + * @param int|null $options A bitmask of the option constants. Defaults to + * NO_ZERO_DIFF | JUST_NOW | ONE_DAY_WORDS | TWO_DAY_WORDS. + * + * @return string The human-readable difference. + * + * @throws \Framework\Exceptions\InvalidDateFormatException When $other cannot be parsed. + * + * @since 3.1.0 + */ + public function diff_for_humans($other = null, $syntax = null, $short = false, $parts = 1, $options = null); + /** * Get the unix timestamp of the instance. * diff --git a/src/Supports/Somoy.php b/src/Supports/Somoy.php index 0d3e672..3d8bee2 100644 --- a/src/Supports/Somoy.php +++ b/src/Supports/Somoy.php @@ -84,6 +84,95 @@ class Somoy extends DateTime implements SomoyInterface */ public const JSON_FORMAT = 'Y-m-d\TH:i:s.u'; + /** + * A diff_for_humans() syntax: no direction wording, e.g. "1 year 2 months". + * + * @var int + * + * @since 3.1.0 + */ + public const DIFF_ABSOLUTE = 1; + + /** + * A diff_for_humans() syntax: resolves to DIFF_RELATIVE_TO_NOW. + * + * @var int + * + * @since 3.1.0 + */ + public const DIFF_RELATIVE_AUTO = 0; + + /** + * A diff_for_humans() syntax: "... ago" / "... from now". + * + * @var int + * + * @since 3.1.0 + */ + public const DIFF_RELATIVE_TO_NOW = 2; + + /** + * A diff_for_humans() syntax: "... before" / "... after". + * + * @var int + * + * @since 3.1.0 + */ + public const DIFF_RELATIVE_TO_OTHER = 3; + + /** + * A diff_for_humans() option: show "1 second ago" rather than "0 seconds + * ago" when the two dates fall on the same second and JUST_NOW does not + * apply. + * + * @var int + * + * @since 3.1.0 + */ + public const NO_ZERO_DIFF = 01; + + /** + * A diff_for_humans() option: report "just now" instead of "0 seconds + * ago" when comparing against now and the dates fall on the same second. + * + * @var int + * + * @since 3.1.0 + */ + public const JUST_NOW = 02; + + /** + * A diff_for_humans() option: report "yesterday" / "tomorrow" instead of + * "1 day ago" / "1 day from now" when comparing against now. + * + * @var int + * + * @since 3.1.0 + */ + public const ONE_DAY_WORDS = 04; + + /** + * A diff_for_humans() option: report "before yesterday" / "after + * tomorrow" instead of "2 days ago" / "2 days from now" when comparing + * against now. + * + * @var int + * + * @since 3.1.0 + */ + public const TWO_DAY_WORDS = 010; + + /** + * A diff_for_humans() option: stop collecting parts at the first + * zero-value unit instead of skipping over it, e.g. with $parts = 3, + * "2 hours 15 seconds" (0 minutes skipped) becomes just "2 hours". + * + * @var int + * + * @since 3.1.0 + */ + public const SEQUENTIAL_PARTS_ONLY = 020; + /** * The units that may be read as properties. * @@ -106,6 +195,25 @@ class Somoy extends DateTime implements SomoyInterface 'week_of_year' => 'W', ]; + /** + * The units used by diff_for_humans(), keyed the way they are read off + * the DateInterval from diff() ("w" is derived from leftover days), each + * paired with its long singular name and short abbreviation. + * + * @var array + * + * @since 3.1.0 + */ + protected static $human_diff_units = [ + 'y' => ['year', 'y'], + 'm' => ['month', 'm'], + 'w' => ['week', 'w'], + 'd' => ['day', 'd'], + 'h' => ['hour', 'h'], + 'i' => ['minute', 'min'], + 's' => ['second', 's'], + ]; + /** * Get an instance for the current date and time. * @@ -1065,6 +1173,134 @@ public function to_json() ->format(static::JSON_FORMAT) . 'Z'; } + /** + * Get a human-readable difference between the instance and another date. + * + * Modeled on Carbon's diffForHumans(): calendar-exact (leap years and + * actual month lengths accounted for), reports up to $parts of the + * largest non-zero units joined by a space, and supports abbreviated + * units and the day-wording / zero-diff options below. + * + * Unlike Carbon, $syntax defaults to DIFF_RELATIVE_TO_NOW ("... ago" / + * "... from now") whether or not $other is given explicitly, rather than + * falling back to "... before" / "... after" whenever $other is passed. + * Pass DIFF_RELATIVE_TO_OTHER explicitly to get that wording. + * + * Carbon's ROUND / FLOOR / CEIL options, which round a smaller unit into + * the last unit shown when $parts truncates the interval, are not + * implemented here; truncated units are simply dropped. + * + * @param DateTimeInterface|string|int|float|null $other The date to + * compare with. Defaults to now. + * @param int|null $syntax One of the DIFF_* constants. Defaults to + * DIFF_RELATIVE_TO_NOW; DIFF_RELATIVE_AUTO behaves the same way. + * @param bool $short Use abbreviated unit names, e.g. "5min" instead of + * "5 minutes". + * @param int $parts How many of the largest non-zero units to include, + * clamped between 1 and 7. + * @param int|null $options A bitmask of NO_ZERO_DIFF, JUST_NOW, + * ONE_DAY_WORDS, TWO_DAY_WORDS and SEQUENTIAL_PARTS_ONLY. Defaults to + * NO_ZERO_DIFF | JUST_NOW | ONE_DAY_WORDS | TWO_DAY_WORDS. + * + * @return string The human-readable difference. + * + * @throws InvalidDateFormatException When $other cannot be parsed. + * + * @since 3.1.0 + */ + public function diff_for_humans( + $other = null, + $syntax = null, + $short = false, + $parts = 1, + $options = null + ) { + $other = $other === null ? static::now($this->get_timezone()) : $this->resolve($other); + + $syntax = $syntax === null ? static::DIFF_RELATIVE_TO_NOW : (int) $syntax; + + if ($syntax === static::DIFF_RELATIVE_AUTO) { + $syntax = static::DIFF_RELATIVE_TO_NOW; + } + + if ($options === null) { + $options = static::NO_ZERO_DIFF | static::JUST_NOW | static::ONE_DAY_WORDS | static::TWO_DAY_WORDS; + } + + $parts = min(7, max(1, (int) $parts)); + $interval = $this->diff($other); + $is_past = $interval->invert === 0; + $is_absolute = $syntax === static::DIFF_ABSOLUTE; + $is_relative_to_now = $syntax === static::DIFF_RELATIVE_TO_NOW; + + $values = [ + 'y' => $interval->y, + 'm' => $interval->m, + 'w' => (int) floor($interval->d / 7), + 'd' => $interval->d % 7, + 'h' => $interval->h, + 'i' => $interval->i, + 's' => $interval->s, + ]; + + $is_whole_day_diff = !$values['y'] && !$values['m'] && !$values['w'] + && !$values['h'] && !$values['i'] && !$values['s']; + + if ($is_relative_to_now && $is_whole_day_diff) { + if ($options & static::TWO_DAY_WORDS && $values['d'] === 2) { + return $is_past ? 'before yesterday' : 'after tomorrow'; + } + + if ($options & static::ONE_DAY_WORDS && $values['d'] === 1) { + return $is_past ? 'yesterday' : 'tomorrow'; + } + } + + $is_zero_diff = !array_filter($values); + + if ($is_relative_to_now && $is_zero_diff && $options & static::JUST_NOW) { + return 'just now'; + } + + if ($is_zero_diff && $options & static::NO_ZERO_DIFF) { + $values['s'] = 1; + } + + $formatted_parts = []; + + foreach ($values as $unit => $value) { + if ($value === 0) { + if ($options & static::SEQUENTIAL_PARTS_ONLY && $formatted_parts) { + break; + } + + continue; + } + + $formatted_parts[] = static::format_human_diff_unit($unit, $value, $short); + + if (count($formatted_parts) >= $parts) { + break; + } + } + + if (!$formatted_parts) { + $formatted_parts[] = static::format_human_diff_unit('s', 0, $short); + } + + $formatted = implode(' ', $formatted_parts); + + if ($is_absolute) { + return $formatted; + } + + if ($is_relative_to_now) { + return $is_past ? $formatted . ' ago' : $formatted . ' from now'; + } + + return $is_past ? $formatted . ' before' : $formatted . ' after'; + } + /** * Convert the instance to a SQL safe date string. * @@ -1363,6 +1599,28 @@ protected static function resolve_timezone($timezone) } } + /** + * Format a single diff_for_humans() unit into its "value unit" string. + * + * @param string $unit The unit key, as used in $human_diff_units (y, m, w, d, h, i, s). + * @param int $value The amount of the unit. + * @param bool $short Whether to use the abbreviated unit name. + * + * @return string The formatted "value unit" pair. + * + * @since 3.1.0 + */ + protected static function format_human_diff_unit($unit, $value, $short) + { + [$name, $short_name] = static::$human_diff_units[$unit]; + + if ($short) { + return $value . $short_name; + } + + return $value . ' ' . ($value === 1 ? $name : $name . 's'); + } + /** * Build an instance from a unix timestamp, in UTC. * diff --git a/tests/Unit/Supports/SomoyTest.php b/tests/Unit/Supports/SomoyTest.php index b1fe558..03f84f7 100644 --- a/tests/Unit/Supports/SomoyTest.php +++ b/tests/Unit/Supports/SomoyTest.php @@ -131,4 +131,162 @@ public function test_today_yesterday_and_tomorrow(): void $this->assertTrue($tomorrow->is_after($today)); $this->assertTrue($today->is_same_day($yesterday->copy()->add_day())); } + + public function test_diff_for_humans_reports_seconds_ago(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + $date = $now->copy()->sub_seconds(1); + + $this->assertSame('1 second ago', $date->diff_for_humans($now)); + } + + public function test_diff_for_humans_reports_plural_minutes_ago(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + $date = $now->copy()->sub_minutes(5); + + $this->assertSame('5 minutes ago', $date->diff_for_humans($now)); + } + + public function test_diff_for_humans_reports_future_dates(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + $date = $now->copy()->add_hours(3); + + $this->assertSame('3 hours from now', $date->diff_for_humans($now)); + } + + public function test_diff_for_humans_defaults_to_now(): void + { + $date = Somoy::now()->sub_minutes(10); + + $this->assertSame('10 minutes ago', $date->diff_for_humans()); + } + + public function test_diff_for_humans_reports_days_under_a_week(): void + { + $now = Somoy::parse('2024-05-10 00:00:00', new DateTimeZone('UTC')); + $date = $now->copy()->sub_days(3); + + $this->assertSame('3 days ago', $date->diff_for_humans($now)); + } + + public function test_diff_for_humans_reports_weeks_from_leftover_days(): void + { + $now = Somoy::parse('2024-05-11 00:00:00', new DateTimeZone('UTC')); + $date = $now->copy()->sub_days(10); + + $this->assertSame('1 week ago', $date->diff_for_humans($now)); + } + + public function test_diff_for_humans_uses_calendar_exact_months(): void + { + // Jan (31 days) + Feb 2023 (28 days, not a leap year) = 59 days, which + // a flat 30-day-per-month approximation would floor down to 1 month. + $earlier = Somoy::parse('2023-01-01 00:00:00', new DateTimeZone('UTC')); + $later = Somoy::parse('2023-03-01 00:00:00', new DateTimeZone('UTC')); + + $this->assertSame('2 months ago', $earlier->diff_for_humans($later)); + } + + public function test_diff_for_humans_reports_just_now_for_the_same_moment(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + + $this->assertSame('just now', $now->diff_for_humans($now->copy())); + } + + public function test_diff_for_humans_supports_short_units(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + $date = $now->copy()->sub_minutes(5); + + $this->assertSame('5min ago', $date->diff_for_humans($now, null, true)); + } + + public function test_diff_for_humans_joins_multiple_parts(): void + { + $later = Somoy::parse('2023-03-01 00:00:00', new DateTimeZone('UTC')); + $earlier = $later->copy()->sub_years(1)->sub_months(2); + + $this->assertSame('1 year 2 months ago', $earlier->diff_for_humans($later, null, false, 2)); + } + + public function test_diff_for_humans_supports_absolute_syntax(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + $date = $now->copy()->sub_hours(3); + + $this->assertSame('3 hours', $date->diff_for_humans($now, Somoy::DIFF_ABSOLUTE)); + } + + public function test_diff_for_humans_supports_relative_to_other_syntax(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + $earlier = $now->copy()->sub_hours(3); + $later = $now->copy()->add_hours(3); + + $this->assertSame('3 hours before', $earlier->diff_for_humans($now, Somoy::DIFF_RELATIVE_TO_OTHER)); + $this->assertSame('3 hours after', $later->diff_for_humans($now, Somoy::DIFF_RELATIVE_TO_OTHER)); + } + + public function test_diff_for_humans_reports_one_day_words(): void + { + $now = Somoy::parse('2024-05-02 00:00:00', new DateTimeZone('UTC')); + $yesterday = $now->copy()->sub_days(1); + $tomorrow = $now->copy()->add_days(1); + + $this->assertSame('yesterday', $yesterday->diff_for_humans($now)); + $this->assertSame('tomorrow', $tomorrow->diff_for_humans($now)); + } + + public function test_diff_for_humans_reports_two_day_words(): void + { + $now = Somoy::parse('2024-05-03 00:00:00', new DateTimeZone('UTC')); + $before_yesterday = $now->copy()->sub_days(2); + $after_tomorrow = $now->copy()->add_days(2); + + $this->assertSame('before yesterday', $before_yesterday->diff_for_humans($now)); + $this->assertSame('after tomorrow', $after_tomorrow->diff_for_humans($now)); + } + + public function test_diff_for_humans_can_disable_day_words(): void + { + $now = Somoy::parse('2024-05-02 00:00:00', new DateTimeZone('UTC')); + $date = $now->copy()->sub_days(1); + $options = Somoy::NO_ZERO_DIFF | Somoy::JUST_NOW; + + $this->assertSame('1 day ago', $date->diff_for_humans($now, null, false, 1, $options)); + } + + public function test_diff_for_humans_reports_zero_diff_without_just_now(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + + $this->assertSame('1 second ago', $now->diff_for_humans($now->copy(), null, false, 1, Somoy::NO_ZERO_DIFF)); + } + + public function test_diff_for_humans_reports_zero_seconds_with_no_zero_diff_options(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + + $this->assertSame('0 seconds', $now->diff_for_humans($now->copy(), Somoy::DIFF_ABSOLUTE, false, 1, 0)); + } + + public function test_diff_for_humans_skips_zero_units_by_default(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + $date = $now->copy()->sub_hours(2)->sub_seconds(15); + + $this->assertSame('2 hours 15 seconds ago', $date->diff_for_humans($now, null, false, 3)); + } + + public function test_diff_for_humans_sequential_parts_only_stops_at_first_zero(): void + { + $now = Somoy::parse('2024-05-01 14:30:45', new DateTimeZone('UTC')); + $date = $now->copy()->sub_hours(2)->sub_seconds(15); + $options = Somoy::SEQUENTIAL_PARTS_ONLY; + + $this->assertSame('2 hours ago', $date->diff_for_humans($now, null, false, 3, $options)); + } }