diff --git a/calendar/classes/output/humandate.php b/calendar/classes/output/humandate.php new file mode 100644 index 00000000000..295aaa93c26 --- /dev/null +++ b/calendar/classes/output/humandate.php @@ -0,0 +1,335 @@ +. + +namespace core_calendar\output; + +use DateInterval; +use DateTimeInterface; +use DateTimeImmutable; +use core\output\pix_icon; +use core\output\templatable; +use core\output\renderable; +use core\output\renderer_base; +use core\clock; +use core\url; + +/** + * Class humandate. + * + * This class is used to render a timestamp as a human readable date. + * The main difference between userdate and this class is that this class + * will render the date as "Today", "Yesterday", "Tomorrow" if the date is + * close to the current date. Also, it will add alert styling if the date + * is near. + * + * @package core_calendar + * @copyright 2024 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class humandate implements renderable, templatable { + + /** @var int|null The number of seconds within which a date is considered near. 1 day by default. */ + protected ?int $near = DAYSECS; + + /** @var bool Whether we should show time only or date and time. */ + protected bool $timeonly = false; + + /** @var url|null Link for the date. */ + protected ?url $link = null; + + /** @var string|null An optional date format to apply. */ + protected ?string $langtimeformat = null; + + /** @var bool Whether to use human relative terminology. */ + protected bool $userelatives = true; + + /** @var clock The clock interface to handle time. */ + protected clock $clock; + + /** + * Class constructor. + * + * Use the factory methods, such as create_from_timestamp or create_from_datetime, instead. + * + * @param DateTimeImmutable $datetime The datetime. + */ + protected function __construct( + /** @var DateTimeImmutable $datetime The datetime. **/ + protected DateTimeImmutable $datetime, + ) { + $this->clock = \core\di::get(clock::class); + } + + /** + * Creates a new humandate instance from a timestamp. + * + * @param int $timestamp The timestamp. + * @param int|null $near The number of seconds within which a date is considered near. 1 day by default. + * @param bool $timeonly Whether we should show time only or date and time. + * @param url|null $link Link for the date. + * @param string|null $langtimeformat An optional date format to apply. + * @param bool $userelatives Whether to use human relative terminology. + * @return humandate The new instance. + */ + public static function create_from_timestamp( + int $timestamp, + ?int $near = DAYSECS, + bool $timeonly = false, + ?url $link = null, + ?string $langtimeformat = null, + bool $userelatives = true, + ): self { + + return self::create_from_datetime( + (new DateTimeImmutable("@{$timestamp}")), + $near, + $timeonly, + $link, + $langtimeformat, + $userelatives + ); + } + + /** + * Creates a new humandate instance from a datetime. + * + * @param DateTimeInterface $datetime The datetime. + * @param int|null $near The number of seconds within which a date is considered near. 1 day by default. + * @param bool $timeonly Whether we should show time only or date and time. + * @param url|null $link Link for the date. + * @param string|null $langtimeformat An optional date format to apply. + * @param bool $userelatives Whether to use human relative terminology. + * @return humandate The new instance. + */ + public static function create_from_datetime( + DateTimeInterface $datetime, + ?int $near = DAYSECS, + bool $timeonly = false, + ?url $link = null, + ?string $langtimeformat = null, + bool $userelatives = true, + ): self { + + if (!($datetime instanceof DateTimeImmutable)) { + // Always use an Immutable object to ensure that the value does not change externally before it is rendered. + $datetime = DateTimeImmutable::createFromInterface($datetime); + } + + return (new self($datetime)) + ->set_near_limit($near) + ->set_display_time_only($timeonly) + ->set_link($link) + ->set_lang_time_format($langtimeformat) + ->set_use_relatives($userelatives); + } + + /** + * Sets the number of seconds within which a date is considered near. + * + * @param int|null $near The number of seconds within which a date is considered near. + * @return humandate The instance. + */ + public function set_near_limit(?int $near): self { + $this->near = $near; + return $this; + } + + /** + * Sets whether we should show time only or date and time. + * + * @param bool $timeonly Whether we should show time only or date and time. + * @return humandate The instance. + */ + public function set_display_time_only(bool $timeonly): self { + $this->timeonly = $timeonly; + return $this; + } + + /** + * Sets the link for the date. If null, no link will be added. + * + * @param url|null $link The link for the date. + * @return humandate The instance. + */ + public function set_link(?url $link): self { + $this->link = $link; + return $this; + } + + /** + * Sets an optional date format to apply. + * + * @param string|null $langtimeformat Lang date and time format to use to format the date. + * @return humandate The instance. + */ + public function set_lang_time_format(?string $langtimeformat): self { + $this->langtimeformat = $langtimeformat; + return $this; + } + + /** + * Sets whether to use human relative terminology. + * + * @param bool $userelatives Whether to use human relative terminology. + * @return humandate The instance. + */ + public function set_use_relatives(bool $userelatives): self { + $this->userelatives = $userelatives; + return $this; + } + + #[\Override] + public function export_for_template(renderer_base $output): array { + $timestamp = $this->datetime->getTimestamp(); + $userdate = userdate($timestamp, get_string('strftimedayshort')); + $relative = null; + if ($this->userelatives) { + $relative = $this->format_relative_date(); + } + + if ($this->timeonly) { + $date = null; + } else { + $date = $relative ?? $userdate; + } + $data = [ + 'timestamp' => $timestamp, + 'userdate' => $userdate, + 'date' => $date, + 'time' => $this->format_time(), + 'ispast' => $this->datetime < $this->clock->now(), + 'needtitle' => ($relative !== null || $this->timeonly), + 'link' => $this->link ? $this->link->out(false) : '', + ]; + if ($this->is_near()) { + $icon = new pix_icon( + pix: 'i/warning', + alt: get_string('warning'), + component: 'moodle', + attributes: ['class' => 'me-0 pb-1'] + ); + $data['isnear'] = true; + $data['nearicon'] = $icon->export_for_template($output); + } + return $data; + } + + /** + * Checks if the date is near. + * + * @return bool Whether the date is near. + */ + private function is_near(): bool { + if ($this->near === null) { + return false; + } + $due = $this->datetime->diff($this->clock->now()); + $intervalseconds = $this->interval_to_seconds($due); + return $intervalseconds < $this->near && $intervalseconds > 0; + } + + /** + * Converts a DateInterval object to total seconds. + * + * @param \DateInterval $interval The interval to convert. + * @return int The total number of seconds. + */ + private function interval_to_seconds(DateInterval $interval): int { + $reference = new DateTimeImmutable(); + $entime = $reference->add($interval); + return $reference->getTimestamp() - $entime->getTimestamp(); + } + + /** + * Formats the timestamp as a relative date string (e.g., "Today", "Yesterday", "Tomorrow"). + * + * This method compares the given timestamp with the current date and returns a formatted + * string representing the relative date. If the timestamp corresponds to today, yesterday, + * or tomorrow, it returns the appropriate string. Otherwise, it returns null. + * + * @return string|null + */ + private function format_relative_date(): ?string { + $usertimestamp = $this->get_user_date($this->datetime->getTimestamp()); + if ($usertimestamp == $this->get_user_date($this->clock->now()->getTimestamp())) { + $format = get_string('strftimerelativetoday', 'langconfig'); + } else if ($usertimestamp == $this->get_user_date(strtotime('yesterday', $this->clock->now()->getTimestamp()))) { + $format = get_string('strftimerelativeyesterday', 'langconfig'); + } else if ($usertimestamp == $this->get_user_date(strtotime('tomorrow', $this->clock->now()->getTimestamp()))) { + $format = get_string('strftimerelativetomorrow', 'langconfig'); + } else { + return null; + } + + return userdate($this->datetime->getTimestamp(), $format); + } + + /** + * Formats the timestamp as a human readable time. + * + * @param int $timestamp The timestamp to format. + * @param string $format The format to use. + * @return string The formatted date. + */ + private function get_user_date(int $timestamp, string $format = '%Y-%m-%d'): string { + $calendartype = \core_calendar\type_factory::get_calendar_instance(); + $timezone = \core_date::get_user_timezone_object(); + return $calendartype->timestamp_to_date_string( + time: $timestamp, + format: $format, + timezone: $timezone->getName(), + fixday: true, + fixhour: true, + ); + } + + /** + * Formats the timestamp as a human readable time. + * + * This method compares the given timestamp with the current date and returns a formatted + * string representing the time. + * + * @return string + */ + private function format_time(): string { + global $CFG; + // Ensure calendar constants are loaded. + require_once($CFG->dirroot . '/calendar/lib.php'); + + $timeformat = get_user_preferences('calendar_timeformat'); + if (empty($timeformat)) { + $timeformat = get_config(null, 'calendar_site_timeformat'); + } + + // Allow language customization of selected time format. + if ($timeformat === CALENDAR_TF_12) { + $timeformat = get_string('strftimetime12', 'langconfig'); + } else if ($timeformat === CALENDAR_TF_24) { + $timeformat = get_string('strftimetime24', 'langconfig'); + } + + if ($timeformat) { + return userdate($this->datetime->getTimestamp(), $timeformat); + } + + // Let's use default format. + if ($this->langtimeformat === null) { + $langtimeformat = get_string('strftimetime'); + } + + return userdate($this->datetime->getTimestamp(), $langtimeformat); + } +} diff --git a/calendar/classes/output/humantimeperiod.php b/calendar/classes/output/humantimeperiod.php new file mode 100644 index 00000000000..ee658555921 --- /dev/null +++ b/calendar/classes/output/humantimeperiod.php @@ -0,0 +1,235 @@ +. + +namespace core_calendar\output; + +Use DateTimeInterface; +use DateTimeImmutable; +use core\output\templatable; +use core\output\renderable; +use core\output\renderer_base; +use core\url; + +/** + * Class humantimeperiod. + * + * This class is used to render a time period as a human readable date. + * The main difference between userdate and this class is that this class + * will render the date as "Today", "Yesterday", "Tomorrow" if the date is + * close to the current date. Also, it will add styling if the date + * is near. + * + * @package core_calendar + * @copyright 2025 Amaia Anabitarte + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class humantimeperiod implements renderable, templatable { + + /** @var int|null Number of seconds that indicates a nearby date. Default to DAYSECS. Use null for no indication. */ + protected $near = DAYSECS; + + /** @var url|null URL to link the date to. */ + protected ?url $link = null; + + /** @var string|null An optional date format to apply. */ + protected ?string $langtimeformat = null; + + /** @var bool Whether to use human common words or not. */ + protected bool $userelatives = true; + + /** + * Class constructor. + * + * @param DateTimeImmutable $startdatetime The starting timestamp. + * @param DateTimeImmutable|null $enddatetime The ending timestamp. + */ + protected function __construct( + /** @var DateTimeImmutable $startdatetime The starting date time. */ + protected DateTimeImmutable $startdatetime, + /** @var DateTimeImmutable|null $enddatetime The ending date time. */ + protected ?DateTimeImmutable $enddatetime, + ) { + } + + /** + * Creates a new humantimeperiod instance from a timestamp. + * + * @param int $starttimestamp The starting timestamp. + * @param int|null $endtimestamp The ending timestamp. + * @param int|null $near The number of seconds that indicates a nearby date. Default to DAYSECS, use null for no indication. + * @param url|null $link URL to link the date to. + * @param string|null $langtimeformat Lang date and time format to use to format the date. + * @param bool $userelatives Whether to use human common words or not. + * @return humantimeperiod The new instance. + */ + public static function create_from_timestamp( + int $starttimestamp, + ?int $endtimestamp, + ?int $near = DAYSECS, + ?url $link = null, + ?string $langtimeformat = null, + bool $userelatives = true, + ): self { + + return self::create_from_datetime( + (new DateTimeImmutable("@{$starttimestamp}")), + $endtimestamp ? (new DateTimeImmutable("@{$endtimestamp}")) : null, + $near, + $link, + $langtimeformat, + $userelatives + ); + } + + /** + * Creates a new humantimeperiod instance from a datetime. + * + * @param DateTimeInterface $startdatetime The starting datetime. + * @param DateTimeInterface|null $enddatetime The ending datetime. + * @param int|null $near The number of seconds that indicates a nearby date. Default to DAYSECS, use null for no indication. + * @param url|null $link URL to link the date to. + * @param string|null $langtimeformat Lang date and time format to use to format the date. + * @param bool $userelatives Whether to use human common words or not. + * @return humantimeperiod The new instance. + */ + public static function create_from_datetime( + DateTimeInterface $startdatetime, + ?DateTimeInterface $enddatetime, + ?int $near = DAYSECS, + ?url $link = null, + ?string $langtimeformat = null, + bool $userelatives = true, + ): self { + + // Always use an Immutable object to ensure that the value does not change externally before it is rendered. + if (!($startdatetime instanceof DateTimeImmutable)) { + $startdatetime = DateTimeImmutable::createFromInterface($startdatetime); + } + if ($enddatetime != null && !($enddatetime instanceof DateTimeImmutable)) { + $enddatetime = DateTimeImmutable::createFromInterface($enddatetime); + } + + return (new self($startdatetime, $enddatetime)) + ->set_near_limit($near) + ->set_link($link) + ->set_lang_time_format($langtimeformat) + ->set_use_relatives($userelatives); + } + + /** + * Sets the number of seconds within which a date is considered near. + * + * @param int|null $near The number of seconds within which a date is considered near. + * @return humantimeperiod The instance. + */ + public function set_near_limit(?int $near): self { + $this->near = $near; + return $this; + } + + /** + * Sets the link for the date. If null, no link will be added. + * + * @param url|null $link The link for the date. + * @return humantimeperiod The instance. + */ + public function set_link(?url $link): self { + $this->link = $link; + return $this; + } + + /** + * Sets an optional date format to apply. + * + * @param string|null $langtimeformat Lang date and time format to use to format the date. + * @return humantimeperiod The instance. + */ + public function set_lang_time_format(?string $langtimeformat): self { + $this->langtimeformat = $langtimeformat; + return $this; + } + + /** + * Sets whether to use human relative terminology. + * + * @param bool $userelatives Whether to use human relative terminology. + * @return humantimeperiod The instance. + */ + public function set_use_relatives(bool $userelatives): self { + $this->userelatives = $userelatives; + return $this; + } + + #[\Override] + public function export_for_template(renderer_base $output): array { + $period = $this->format_period(); + return [ + 'startdate' => $period['startdate']->export_for_template($output), + 'enddate' => $period['enddate'] ? $period['enddate']->export_for_template($output) : null, + ]; + } + + /** + * Format a time periods based on 2 dates. + * + * @return array An array of one or two humandate elements. + */ + private function format_period(): array { + + $linkstart = null; + $linkend = null; + if ($this->link) { + $linkstart = new url($this->link, ['view' => 'day', 'time' => $this->startdatetime->getTimestamp()]); + $linkend = new url($this->link, ['view' => 'day', 'time' => $this->enddatetime->getTimestamp()]); + } + + $startdate = humandate::create_from_datetime( + datetime: $this->startdatetime, + near: $this->near, + link: $linkstart, + langtimeformat: $this->langtimeformat, + userelatives: $this->userelatives + ); + + if ($this->enddatetime == null || $this->startdatetime == $this->enddatetime) { + return [ + 'startdate' => $startdate, + 'enddate' => null, + ]; + } + + // Get the midnight of the day the event will start. + $usermidnightstart = usergetmidnight($this->startdatetime->getTimestamp()); + // Get the midnight of the day the event will end. + $usermidnightend = usergetmidnight($this->enddatetime->getTimestamp()); + // Check if we will still be on the same day. + $issameday = ($usermidnightstart == $usermidnightend); + + $enddate = humandate::create_from_datetime( + datetime: $this->enddatetime, + near: $this->near, + timeonly: $issameday, + link: $linkend, + langtimeformat: $this->langtimeformat, + userelatives: $this->userelatives + ); + + return [ + 'startdate' => $startdate, + 'enddate' => $enddate, + ]; + } +} diff --git a/calendar/templates/humandate.mustache b/calendar/templates/humandate.mustache new file mode 100644 index 00000000000..1b081844e41 --- /dev/null +++ b/calendar/templates/humandate.mustache @@ -0,0 +1,56 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_calendar/humandate + + Print a human readable date format. + + Example context (json): + { + "userdate": "2021-09-01", + "date": "Tomorrow, 23 January", + "time": "12:00 AM", + "timestamp": "1630512000", + "ispast": false, + "isnear": true, + "nearicon": { + "extraclasses": "me-0 pb-1", + "attributes": [ + {"name": "src", "value": "../../../pix/i/warning.svg"}, + {"name": "alt", "value": "Warning"} + ] + }, + "needtitle": true, + "link": "https://example.com/calendar/view.php" + } +}} +{{#link}} + +{{/link}} + + {{#nearicon}} + {{>core/pix_icon}} + {{/nearicon}} + {{#date}}{{date}}{{#time}}, {{/time}}{{/date}}{{time}} + +{{#link}} + +{{/link}} diff --git a/calendar/templates/humantimeperiod.mustache b/calendar/templates/humantimeperiod.mustache new file mode 100644 index 00000000000..bfe9c150609 --- /dev/null +++ b/calendar/templates/humantimeperiod.mustache @@ -0,0 +1,62 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_calendar/humantimeperiod + + Print a human readable time period. + + Example context (json): + { + "startdate": { + "userdate": "2021-09-01", + "date": "Tomorrow 12:00 AM", + "timestamp": "1630512000", + "ispast": false, + "isnear": true, + "nearicon": { + "extraclasses": "me-0 pb-1", + "attributes": [ + {"name": "src", "value": "../../../pix/i/warning.svg"}, + {"name": "alt", "value": "Warning"} + ] + }, + "needtitle": true + }, + "enddate": { + "userdate": "2021-09-01", + "date": "Tomorrow 12:00 AM", + "timestamp": "1630512000", + "ispast": false, + "isnear": true, + "nearicon": { + "extraclasses": "me-0 pb-1", + "attributes": [ + {"name": "src", "value": "../../../pix/i/warning.svg"}, + {"name": "alt", "value": "Warning"} + ] + }, + "needtitle": true + } + } +}} + +{{#startdate}} + {{> core_calendar/humandate }} +{{/startdate}} +{{#enddate}} » + {{> core_calendar/humandate }} +{{/enddate}} diff --git a/calendar/tests/behat/humandate.feature b/calendar/tests/behat/humandate.feature new file mode 100644 index 00000000000..6d98246e9b0 --- /dev/null +++ b/calendar/tests/behat/humandate.feature @@ -0,0 +1,62 @@ +@core @core_calendar +Feature: Confirm dates are human readable + In order to ensure the calendar dates are human readable + As an admin + I need to create calendar events in near days + + Background: + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "blocks" exist: + | blockname | contextlevel | reference | pagetypepattern | defaultregion | + | calendar_upcoming | System | 1 | my-index | content | + + @javascript + Scenario: Create human readable events + Given I log in as "admin" + And the following "events" exist: + | name | eventtype | timestart | timeduration | +# Starts today and ends today in the future + | This day one hour event | user | ##today midnight +1 seconds## | 86398 | +# Starts today, ends tomorrow + | This day and 1 day event | user | ##today midnight +1 seconds## | 87000 | +# Starts yesterday, ends today in the future + | Last day one day event | user | ##today noon -1 days## | 129598 | +# Starts yesterday, ends in the past + | Last day less than one day event | user | ##today noon -1 days## | 86400 | +# Starts tomorrow + | Next day event | user | ##today midnight +1 days## | 86400 | +# Far in the future + | Future event | user | ##today noon +2 days## | 86400 | + When I am on homepage + And I click on "This day one hour event" "link" + Then I should see "Today" in the "This day one hour event" "dialogue" + And I should not see "Yesterday" in the "This day one hour event" "dialogue" + And I should not see "Tomorrow" in the "This day one hour event" "dialogue" + And "Warning" "icon" should exist in the "This day one hour event" "dialogue" + And I click on "Close" "button" in the "This day one hour event" "dialogue" + And I click on "This day and 1 day event" "link" + And I should see "Today" in the "This day and 1 day event" "dialogue" + And I should not see "Yesterday" in the "This day and 1 day event" "dialogue" + And I should see "Tomorrow" in the "This day and 1 day event" "dialogue" + And "Warning" "icon" should exist in the "This day and 1 day event" "dialogue" + And I click on "Close" "button" in the "This day and 1 day event" "dialogue" + And I click on "Last day one day event" "link" + And I should see "Today" in the "Last day one day event" "dialogue" + And I should see "Yesterday" in the "Last day one day event" "dialogue" + And I should not see "Tomorrow" in the "Last day one day event" "dialogue" + And "Warning" "icon" should exist in the "Last day one day event" "dialogue" + And I click on "Close" "button" in the "Last day one day event" "dialogue" + And I click on "Next day event" "link" + And I should not see "Today" in the "Next day event" "dialogue" + And I should not see "Yesterday" in the "Next day event" "dialogue" + And I should see "Tomorrow" in the "Next day event" "dialogue" + And "Warning" "icon" should exist in the "Next day event" "dialogue" + And I click on "Close" "button" in the "Next day event" "dialogue" + And I click on "Future event" "link" + And I should not see "Today" in the "Future event" "dialogue" + And I should not see "Yesterday" in the "Future event" "dialogue" + And I should not see "Tomorrow" in the "Future event" "dialogue" + And "Warning" "icon" should not exist in the "Future event" "dialogue" + And I click on "Close" "button" in the "Future event" "dialogue" diff --git a/calendar/tests/output/humandate_test.php b/calendar/tests/output/humandate_test.php new file mode 100644 index 00000000000..a7ec968d27e --- /dev/null +++ b/calendar/tests/output/humandate_test.php @@ -0,0 +1,210 @@ +. + +namespace core_calendar\output; + +use DateTime; + +/** + * Tests for humandate class. + * + * @covers \core_calendar\output\humandate + * @package core_calendar + * @category test + * @copyright 2025 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class humandate_test extends \advanced_testcase { + + /** + * Test export_for_template() method. + * + * @dataProvider provider_export_for_template + * @param int $addseconds The number of seconds to add to the current time. + * @param bool $userelatives Whether to use relative dates. + * @param string|null $date For relative dates, the expected string (Tomorrow, Today, Yesterday). + * @param bool $ispast Whether the date is in the past. + * @param bool $needtitle Whether the date needs a title. + * @param bool $isnear Whether the date is near. + */ + public function test_export_for_template( + int $addseconds, + bool $userelatives, + ?string $date, + bool $ispast, + bool $needtitle, + bool $isnear, + ): void { + global $PAGE; + + $this->resetAfterTest(); + + $clock = $this->mock_clock_with_frozen(); + $renderer = $PAGE->get_renderer('core'); + + $timestamp = $clock->time() + $addseconds; + $expected = [ + 'timestamp' => $timestamp, + 'date' => $date, + 'userdate' => userdate($timestamp, get_string('strftimedayshort')), + 'ispast' => $ispast, + 'needtitle' => $needtitle, + 'isnear' => $isnear, + ]; + $humandate = humandate::create_from_timestamp($timestamp); + $humandate->set_use_relatives($userelatives); + $result = $humandate->export_for_template($renderer); + $this->compare_output($expected, $result, $userelatives); + } + + /** + * Data provider. + * + * @return array + */ + public static function provider_export_for_template(): array { + return [ + 'Now with relatives' => [ + 'addseconds' => 0, + 'userelatives' => true, + 'date' => 'Today', + 'ispast' => true, + 'needtitle' => true, + 'isnear' => false, + ], + 'Tomorrow with relatives' => [ + 'addseconds' => 87000, + 'userelatives' => true, + 'date' => 'Tomorrow', + 'ispast' => false, + 'needtitle' => true, + 'isnear' => false, + ], + 'Yesterday with relatives' => [ + 'addseconds' => -87000, + 'userelatives' => true, + 'date' => 'Yesterday', + 'ispast' => true, + 'needtitle' => true, + 'isnear' => false, + ], + 'One hour future with relatives' => [ + 'addseconds' => 3600, + 'userelatives' => true, + 'date' => null, + 'ispast' => false, + 'needtitle' => true, + 'isnear' => true, + ], + 'One hour past with relatives' => [ + 'addseconds' => -3600, + 'userelatives' => true, + 'date' => null, + 'ispast' => true, + 'needtitle' => true, + 'isnear' => false, + ], + 'Now without relatives' => [ + 'addseconds' => 0, + 'userelatives' => false, + 'date' => 'Today', + 'ispast' => true, + 'needtitle' => false, + 'isnear' => false, + ], + 'Tomorrow without relatives' => [ + 'addseconds' => 87000, + 'userelatives' => false, + 'date' => 'Tomorrow', + 'ispast' => false, + 'needtitle' => false, + 'isnear' => false, + ], + 'Yesterday without relatives' => [ + 'addseconds' => -87000, + 'userelatives' => false, + 'date' => 'Yesterday', + 'ispast' => true, + 'needtitle' => false, + 'isnear' => false, + ], + 'One hour future without relatives' => [ + 'addseconds' => 3600, + 'userelatives' => false, + 'date' => null, + 'ispast' => false, + 'needtitle' => false, + 'isnear' => true, + ], + 'One hour past without relatives' => [ + 'addseconds' => -3600, + 'userelatives' => false, + 'date' => null, + 'ispast' => true, + 'needtitle' => false, + 'isnear' => false, + ], + ]; + } + + public function test_create_from_timestamp(): void { + $this->resetAfterTest(); + + $clock = $this->mock_clock_with_frozen(); + $timestamp = $clock->time(); + $humandate = humandate::create_from_timestamp($timestamp); + $this->assertInstanceOf(humandate::class, $humandate); + } + + public function test_create_from_datetime(): void { + $this->resetAfterTest(); + + $humandate = humandate::create_from_datetime(new DateTime()); + $this->assertInstanceOf(humandate::class, $humandate); + } + + /** + * Compare humandate output. + * + * @param array $expected The expected output. + * @param array $actual The actual output. + * @param bool $userelatives Whether to use relative dates. + */ + protected function compare_output( + array $expected, + array $actual, + bool $userelatives, + ): void { + $fields = ['timestamp', 'userdate', 'ispast', 'needtitle']; + foreach ($fields as $field) { + $this->assertEquals($expected[$field], $actual[$field], "Field $field does not match"); + } + + if ($expected['isnear']) { + $this->assertEquals($expected[$field], $actual[$field], "Field isnear does not match"); + } else { + $this->assertArrayNotHasKey('isnear', $actual); + } + + if (!is_null($expected['date'])) { + if ($userelatives) { + $this->assertStringContainsString($expected['date'], $actual['date']); + } else { + $this->assertStringNotContainsString($expected['date'], $actual['date']); + } + } + } +} diff --git a/calendar/tests/output/humantimeperiod_test.php b/calendar/tests/output/humantimeperiod_test.php new file mode 100644 index 00000000000..5413cfd49d3 --- /dev/null +++ b/calendar/tests/output/humantimeperiod_test.php @@ -0,0 +1,166 @@ +. + +namespace core_calendar\output; + +use DateTime; + +/** + * Tests for humantimeperiod_test class. + * + * @covers \core_calendar\output\humantimeperiod + * @package core_calendar + * @category test + * @copyright 2025 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class humantimeperiod_test extends \advanced_testcase { + + /** + * Test format_period() method. + * + * @dataProvider provider_format_period + * @param int|null $addsecondsend The number of seconds to add to the current time for the end date. + * @param bool $expectedendnull Whether the end date is null. + */ + public function test_format_period( + ?int $addsecondsend, + bool $expectedendnull, + ): void { + $this->resetAfterTest(); + + // 26 February 2025 15:30:00 (GMT). + $clock = $this->mock_clock_with_frozen(1740583800); + + $timestampstart = $clock->time(); + $timestampend = is_null($addsecondsend) ? null : $clock->time() + $addsecondsend; + $humandateend = null; + if (!$expectedendnull) { + $humandateend = humandate::create_from_timestamp( + timestamp: $timestampend, + timeonly: (abs($addsecondsend) < 1800), + ); + } + $expected = [ + 'startdate' => humandate::create_from_timestamp($timestampstart), + 'enddate' => $humandateend, + ]; + $humantimeperiod = humantimeperiod::create_from_timestamp($timestampstart, $timestampend); + + $reflection = new \ReflectionClass($humantimeperiod); + $method = $reflection->getMethod('format_period'); + $method->setAccessible(true); + $result = $method->invoke($humantimeperiod); + $this->compare_output($expected, $result); + } + + /** + * Data provider. + * + * @return array + */ + public static function provider_format_period(): array { + return [ + 'Same start and end' => [ + 'addsecondsend' => 0, + 'expectedendnull' => true, + ], + 'Null end date' => [ + 'addsecondsend' => null, + 'expectedendnull' => true, + ], + '1 day ahead' => [ + 'addsecondsend' => 87000, + 'expectedendnull' => false, + ], + '1 day behind' => [ + 'addsecondsend' => -87000, + 'expectedendnull' => false, + ], + '29 minutes ahead' => [ + 'addsecondsend' => 1799, + 'expectedendnull' => false, + ], + '30 minutes ahead (Midnight)' => [ + 'addsecondsend' => 1800, + 'expectedendnull' => false, + ], + '31 minutes ahead (Midnight)' => [ + 'addsecondsend' => 1801, + 'expectedendnull' => false, + ], + ]; + } + + /** + * Test create_from_timestamp. + */ + public function test_create_from_timestamp(): void { + $this->resetAfterTest(); + + $clock = \core\di::get(\core\clock::class); + $timestamp = $clock->time(); + $humantimeperiod = humantimeperiod::create_from_timestamp($timestamp, $timestamp + 3600); + $this->assertInstanceOf(humantimeperiod::class, $humantimeperiod); + } + + /** + * Test create_from_timestamp with enddatetime null. + */ + public function test_create_from_timestamp_endnull(): void { + $this->resetAfterTest(); + + $clock = $this->mock_clock_with_frozen(); + $timestamp = $clock->time(); + $humantimeperiod = humantimeperiod::create_from_timestamp($timestamp, null); + $this->assertInstanceOf(humantimeperiod::class, $humantimeperiod); + } + + /** + * Test create_from_datetime. + */ + public function test_create_from_datetime(): void { + $this->resetAfterTest(); + + $humantimeperiod = humantimeperiod::create_from_datetime(new DateTime(), new DateTime('+1 hour')); + $this->assertInstanceOf(humantimeperiod::class, $humantimeperiod); + } + + /** + * Test create_from_datetime with enddatetime null. + */ + public function test_create_from_datetime_endnull(): void { + $this->resetAfterTest(); + + $humantimeperiod = humantimeperiod::create_from_datetime(new DateTime(), null); + $this->assertInstanceOf(humantimeperiod::class, $humantimeperiod); + } + /** + * Compare humantimeperiod output. + * + * @param array $expected The expected output. + * @param array $actual The actual output. + */ + protected function compare_output( + array $expected, + array $actual, + ): void { + $fields = ['startdate', 'enddate']; + foreach ($fields as $field) { + $this->assertEquals($expected[$field], $actual[$field], "Field $field does not match"); + } + } +} diff --git a/lang/en/langconfig.php b/lang/en/langconfig.php index 5b68fdd9ebc..f5a099cf1f9 100644 --- a/lang/en/langconfig.php +++ b/lang/en/langconfig.php @@ -189,6 +189,9 @@ $string['strftimemonth'] = '%B'; $string['strftimemonthyear'] = '%B %Y'; $string['strftimerecent'] = '%d %b, %H:%M'; $string['strftimerecentfull'] = '%a, %d %b %Y, %I:%M %p'; +$string['strftimerelativetoday'] = 'Today, %d %B'; +$string['strftimerelativetomorrow'] = 'Tomorrow, %d %B'; +$string['strftimerelativeyesterday'] = 'Yesterday, %d %B'; $string['strftimetime'] = '%I:%M %p'; $string['strftimetime12'] = '%I:%M %p'; $string['strftimetime24'] = '%H:%M';