diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index df93bc716..b6b60cc11 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -4,14 +4,19 @@ namespace Icinga\Module\Notifications\Controllers; +use DateTime; +use DateTimeZone; use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Forms\MoveRotationForm; use Icinga\Module\Notifications\Forms\RotationConfigForm; use Icinga\Module\Notifications\Forms\ScheduleForm; use Icinga\Module\Notifications\Model\Schedule; -use Icinga\Module\Notifications\Widget\RecipientSuggestions; +use Icinga\Module\Notifications\Web\Control\TimezonePicker; use Icinga\Module\Notifications\Widget\Detail\ScheduleDetail; +use Icinga\Module\Notifications\Widget\RecipientSuggestions; +use Icinga\Module\Notifications\Widget\TimezoneWarning; +use Icinga\Web\Session; use ipl\Html\Form; use ipl\Html\Html; use ipl\Stdlib\Filter; @@ -55,8 +60,18 @@ public function indexAction(): void }) ->handleRequest($this->getServerRequest()); + $timezonePicker = $this->createTimezonePicker($schedule->timezone, $id); + + $this->addControl($timezonePicker); $this->addControl($scheduleControls); - $this->addContent(new ScheduleDetail($schedule, $scheduleControls)); + $this->addContent(new ScheduleDetail( + $schedule, + $scheduleControls, + new DateTime('today', new DateTimeZone( + $timezonePicker->getValue(TimezonePicker::DEFAULT_TIMEZONE_PARAM) + ?? $this->getDisplayTimezoneFromSession($schedule->timezone) + )), + )); } public function settingsAction(): void @@ -92,7 +107,8 @@ public function addAction(): void { $this->setTitle($this->translate('New Schedule')); $form = (new ScheduleForm(Database::get())) - ->setAction($this->getRequest()->getUrl()->getAbsoluteUrl()) + ->setShowTimezoneSuggestionInput() + ->setAction($this->getRequest()->getUrl()->setParam('showCompact')->getAbsoluteUrl()) ->on(Form::ON_SUCCESS, function (ScheduleForm $form) { $scheduleId = $form->addSchedule(); @@ -108,9 +124,15 @@ public function addAction(): void public function addRotationAction(): void { $scheduleId = (int) $this->params->getRequired('schedule'); + $scheduleTimezone = $this->getScheduleTimezone($scheduleId); + $displayTimezone = $this->getDisplayTimezoneFromSession($scheduleTimezone); $this->setTitle($this->translate('Add Rotation')); - $form = new RotationConfigForm($scheduleId, Database::get()); + if ($displayTimezone !== $scheduleTimezone) { + $this->addContent(new TimezoneWarning($scheduleTimezone)); + } + + $form = new RotationConfigForm($scheduleId, Database::get(), $displayTimezone, $scheduleTimezone); $form->setAction($this->getRequest()->getUrl()->setParam('showCompact')->getAbsoluteUrl()); $form->setSuggestionUrl(Url::fromPath('notifications/schedule/suggest-recipient')); $form->on(RotationConfigForm::ON_SENT, function ($form) { @@ -141,9 +163,15 @@ public function editRotationAction(): void { $id = (int) $this->params->getRequired('id'); $scheduleId = (int) $this->params->getRequired('schedule'); + $scheduleTimezone = $this->getScheduleTimezone($scheduleId); + $displayTimezone = $this->getDisplayTimezoneFromSession($scheduleTimezone); $this->setTitle($this->translate('Edit Rotation')); - $form = new RotationConfigForm($scheduleId, Database::get()); + if ($displayTimezone !== $scheduleTimezone) { + $this->addContent(new TimezoneWarning($scheduleTimezone)); + } + + $form = new RotationConfigForm($scheduleId, Database::get(), $displayTimezone, $scheduleTimezone); $form->disableModeSelection(); $form->setShowRemoveButton(); $form->loadRotation($id); @@ -204,4 +232,71 @@ public function suggestRecipientAction(): void $this->getDocument()->addHtml($suggestions); } + + /** + * Get the timezone of a schedule + * + * @param int $scheduleId The ID of the schedule + * + * @return string The timezone of the schedule + */ + protected function getScheduleTimezone(int $scheduleId): string + { + return Schedule::on(Database::get()) + ->filter(Filter::equal('schedule.id', $scheduleId)) + ->first() + ->timezone; + } + + /** + * Create a timezone picker control + * + * @param string $scheduleTimezone The schedule timezone is used as default if no timezone is in the session + * @param int $scheduleId The schedule id + * + * @return TimezonePicker The timezone picker control + */ + protected function createTimezonePicker(string $scheduleTimezone, int $scheduleId): TimezonePicker + { + $defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM; + return (new TimezonePicker()) + ->populate([ + $defaultTimezoneParam => $this->params->get($defaultTimezoneParam) + ?? $this->getDisplayTimezoneFromSession($scheduleTimezone) + ]) + ->on( + TimezonePicker::ON_SUBMIT, + function (TimezonePicker $timezonePicker) use ($defaultTimezoneParam, $scheduleId, $scheduleTimezone) { + $this->writeDisplayTimezoneToSession($timezonePicker->getValue($defaultTimezoneParam)); + $this->redirectNow(Links::schedule($scheduleId)->with([ + $defaultTimezoneParam => $timezonePicker->getValue($defaultTimezoneParam) + ])); + } + ) + ->handleRequest($this->getServerRequest()); + } + + /** + * Get the display timezone from the session + * + * @param string|null $defaultTimezone + * + * @return string + */ + protected function getDisplayTimezoneFromSession(?string $defaultTimezone = null): string + { + return Session::getSession()->getNamespace('notifications')->get('schedule.display_timezone', $defaultTimezone); + } + + /** + * Write the display timezone to the session + * + * @param string $displayTimezone + * + * @return void + */ + protected function writeDisplayTimezoneToSession(string $displayTimezone): void + { + Session::getSession()->getNamespace('notifications')->set('schedule.display_timezone', $displayTimezone); + } } diff --git a/application/controllers/SchedulesController.php b/application/controllers/SchedulesController.php index 2a022da6c..a9c212486 100644 --- a/application/controllers/SchedulesController.php +++ b/application/controllers/SchedulesController.php @@ -4,16 +4,12 @@ namespace Icinga\Module\Notifications\Controllers; -use DateTime; use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Model\Schedule; use Icinga\Module\Notifications\View\ScheduleRenderer; use Icinga\Module\Notifications\Web\Control\SearchBar\ObjectSuggestions; use Icinga\Module\Notifications\Widget\ItemList\ObjectList; -use Icinga\Module\Notifications\Widget\TimeGrid\DaysHeader; -use ipl\Html\Attributes; -use ipl\Html\HtmlElement; use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; use ipl\Web\Compat\SearchControls; @@ -78,12 +74,6 @@ public function indexAction(): void ))->openInModal() ); - $this->addContent(new HtmlElement( - 'div', - Attributes::create(['class' => 'schedules-header']), - new DaysHeader((new DateTime())->setTime(0, 0), 7) - )); - $this->addContent(new ObjectList($schedules, new ScheduleRenderer())); if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { diff --git a/application/controllers/SuggestController.php b/application/controllers/SuggestController.php new file mode 100644 index 000000000..9a1167071 --- /dev/null +++ b/application/controllers/SuggestController.php @@ -0,0 +1,35 @@ +getTimezone()->getLocation() + && $suggestions->matchSearch($tz) + ) { + yield ['search' => $tz]; + } + } catch (Throwable) { + continue; + } + } + })()); + + $this->getDocument()->addHtml($suggestions->forRequest($this->getServerRequest())); + } +} diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index 8e829bab1..28c83afa9 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -6,6 +6,7 @@ use DateInterval; use DateTime; +use DateTimeZone; use Generator; use Icinga\Exception\ConfigurationError; use Icinga\Exception\Http\HttpNotFoundException; @@ -80,6 +81,12 @@ class RotationConfigForm extends CompatForm /** @var int The rotation id */ protected $rotationId; + /** @var string The timezone to display the timeline in */ + protected $displayTimezone; + + /** @var string The timezone the schedule is created in */ + protected $scheduleTimezone; + /** * Set the label for the submit button * @@ -187,11 +194,15 @@ public function hasBeenWiped(): bool * * @param int $scheduleId * @param Connection $db + * @param string $displayTimezone + * @param string $scheduleTimezone */ - public function __construct(int $scheduleId, Connection $db) + public function __construct(int $scheduleId, Connection $db, string $displayTimezone, string $scheduleTimezone) { $this->db = $db; $this->scheduleId = $scheduleId; + $this->displayTimezone = $displayTimezone; + $this->scheduleTimezone = $scheduleTimezone; $this->applyDefaultElementDecorators(); } @@ -227,7 +238,11 @@ public function loadRotation(int $rotationId): self throw new LogicException('Invalid mode'); } - $handoff = DateTime::createFromFormat('Y-m-d H:i', $rotation->first_handoff . ' ' . $time); + $handoff = DateTime::createFromFormat( + 'Y-m-d H:i', + $rotation->first_handoff . ' ' . $time, + new DateTimeZone($this->scheduleTimezone) + ); if ($handoff === false) { throw new ConfigurationError('Invalid date format'); } @@ -258,7 +273,9 @@ public function loadRotation(int $rotationId): self ->orderBy('until_time', SORT_DESC) ->first(); if ($previousShift !== null) { - $this->previousShift = $previousShift->until_time; + $this->previousShift = $previousShift->until_time->setTimezone( + new DateTimeZone($this->scheduleTimezone) + ); } /** @var ?Rotation $newerRotation */ @@ -452,6 +469,9 @@ public function editRotation(int $rotationId): void ->filter(Filter::equal('timeperiod.owned_by_rotation_id', $rotationId)); foreach ($timeperiodEntries as $timeperiodEntry) { + $timeperiodEntry->start_time->setTimezone(new DateTimeZone($this->scheduleTimezone)); + $timeperiodEntry->end_time->setTimezone(new DateTimeZone($this->scheduleTimezone)); + /** @var TimeperiodEntry $timeperiodEntry */ $rrule = $timeperiodEntry->toRecurrenceRule(); $shiftDuration = $timeperiodEntry->start_time->diff($timeperiodEntry->end_time); @@ -819,9 +839,10 @@ protected function assemblePartialDayOptions(FieldsetElement $options): DateTime ->replaceDecorator('Description', DescriptionDecorator::class, ['class' => 'description']); $selectedFromTime = $from->getValue(); + $nextDayTimeOptions = []; foreach ($timeOptions as $key => $value) { - unset($timeOptions[$key]); // unset to re-add it at the end of array - $timeOptions[$key] = sprintf('%s (%s)', $value, $this->translate('Next Day')); + unset($timeOptions[$key]); + $nextDayTimeOptions[$key] = $value; if ($selectedFromTime === $key) { break; @@ -830,7 +851,9 @@ protected function assemblePartialDayOptions(FieldsetElement $options): DateTime $to = $options->createElement('select', 'to', [ 'required' => true, - 'options' => $timeOptions + 'options' => empty($timeOptions) + ? [$this->translate('Next Day') => $nextDayTimeOptions] + : [$this->translate('Same Day') => $timeOptions, $this->translate('Next Day') => $nextDayTimeOptions] ]); $options->registerElement($to); @@ -1226,11 +1249,35 @@ function ($value, $validator) use ($earliestHandoff, $firstHandoff, $latestHando (new \IntlDateFormatter( \Locale::getDefault(), \IntlDateFormatter::MEDIUM, - \IntlDateFormatter::SHORT + \IntlDateFormatter::SHORT, + $this->scheduleTimezone ))->format($actualFirstHandoff) ); } - }) + }), + new HtmlElement('br'), + $this->displayTimezone !== $this->scheduleTimezone ? DeferredText::create(function () { + $ruleGenerator = $this->yieldRecurrenceRules(1); + if (! $ruleGenerator->valid()) { + return ''; + } + + $actualFirstHandoff = $ruleGenerator->current()[0]->getStartDate(); + if ($actualFirstHandoff < new DateTime()) { + return ''; + } else { + return sprintf( + $this->translate('In your chosen display timezone (%s) this is the %s'), + $this->displayTimezone, + (new \IntlDateFormatter( + \Locale::getDefault(), + \IntlDateFormatter::MEDIUM, + \IntlDateFormatter::SHORT, + $this->displayTimezone + ))->format($actualFirstHandoff) + ); + } + }) : new HtmlDocument() )); } @@ -1293,12 +1340,13 @@ private function parseDateAndTime(?string $date = null, ?string $time = null): D } if (! $format) { - return (new DateTime())->setTime(0, 0); + return new DateTime('today', new DateTimeZone($this->scheduleTimezone)); } - $datetime = DateTime::createFromFormat($format, $expression); + $datetime = DateTime::createFromFormat($format, $expression, new DateTimeZone($this->scheduleTimezone)); + if ($datetime === false) { - $datetime = (new DateTime())->setTime(0, 0); + $datetime = new DateTime('today', $this->scheduleTimezone); } elseif ($time === null) { $datetime->setTime(0, 0); } @@ -1316,11 +1364,12 @@ private function getTimeOptions(): array $formatter = new \IntlDateFormatter( \Locale::getDefault(), \IntlDateFormatter::NONE, - \IntlDateFormatter::SHORT + \IntlDateFormatter::SHORT, + $this->scheduleTimezone ); $options = []; - $dt = new DateTime(); + $dt = new DateTime('now', new DateTimeZone($this->scheduleTimezone)); for ($hour = 0; $hour < 24; $hour++) { for ($minute = 0; $minute < 60; $minute += 30) { $dt->setTime($hour, $minute); diff --git a/application/forms/ScheduleForm.php b/application/forms/ScheduleForm.php index 5f3620b1c..3adb7ce85 100644 --- a/application/forms/ScheduleForm.php +++ b/application/forms/ScheduleForm.php @@ -5,19 +5,24 @@ namespace Icinga\Module\Notifications\Forms; use DateTime; +use DateTimeZone; use Icinga\Exception\Http\HttpNotFoundException; use Icinga\Module\Notifications\Model\Rotation; use Icinga\Module\Notifications\Model\RuleEscalationRecipient; use Icinga\Module\Notifications\Model\Schedule; use Icinga\Web\Session; +use IntlTimeZone; use ipl\Html\Attributes; use ipl\Html\HtmlDocument; use ipl\Html\HtmlElement; use ipl\Html\Text; use ipl\Sql\Connection; use ipl\Stdlib\Filter; +use ipl\Validator\CallbackValidator; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; +use Throwable; class ScheduleForm extends CompatForm { @@ -29,6 +34,9 @@ class ScheduleForm extends CompatForm /** @var bool */ protected bool $showRemoveButton = false; + /** @var bool */ + protected bool $showTimezoneSuggestionInput = false; + /** @var Connection */ private Connection $db; @@ -60,6 +68,20 @@ public function setShowRemoveButton(bool $state = true): self return $this; } + /** + * Set whether to show the timezone dropdown or not + * + * @param bool $state If true, the timezone dropdown will be shown (defaults to true) + * + * @return $this + */ + public function setShowTimezoneSuggestionInput(bool $state = true): self + { + $this->showTimezoneSuggestionInput = $state; + + return $this; + } + public function hasBeenRemoved(): bool { $btn = $this->getPressedSubmitElement(); @@ -78,8 +100,9 @@ public function addSchedule(): int { return $this->db->transaction(function (Connection $db) { $db->insert('schedule', [ - 'name' => $this->getValue('name'), - 'changed_at' => (int) (new DateTime())->format("Uv") + 'name' => $this->getValue('name'), + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'timezone' => $this->getValue('timezone') ]); return $db->lastInsertId(); @@ -176,6 +199,41 @@ protected function assemble(): void 'placeholder' => $this->translate('e.g. working hours, on call, etc ...') ]); + if ($this->showTimezoneSuggestionInput) { + $this->addElement( + 'suggestion', + 'timezone', + [ + 'suggestionsUrl' => Url::fromPath('notifications/suggest/timezone', [ + 'showCompact' => true, + '_disableLayout' => 1 + ]), + 'label' => $this->translate('Schedule Timezone'), + 'value' => date_default_timezone_get(), + 'validators' => [ + new CallbackValidator(function ($value, $validator) { + foreach (IntlTimeZone::createEnumeration() as $tz) { + try { + if ( + (new DateTime('now', new DateTimeZone($tz)))->getTimezone()->getLocation() + && $value === $tz + ) { + return true; + } + } catch (Throwable) { + continue; + } + } + + $validator->addMessage($this->translate('Invalid timezone')); + + return false; + }) + ] + ] + ); + } + $this->addElement('submit', 'submit', [ 'label' => $this->getSubmitLabel() ]); diff --git a/library/Notifications/Model/Schedule.php b/library/Notifications/Model/Schedule.php index c3f82339c..af1aef4e3 100644 --- a/library/Notifications/Model/Schedule.php +++ b/library/Notifications/Model/Schedule.php @@ -15,6 +15,7 @@ /** * @property int $id * @property string $name + * @property string $timezone * @property DateTime $changed_at * @property bool $deleted * @@ -39,6 +40,7 @@ public function getColumns(): array return [ 'name', 'changed_at', + 'timezone', 'deleted' ]; } @@ -47,7 +49,8 @@ public function getColumnDefinitions(): array { return [ 'name' => t('Name'), - 'changed_at' => t('Changed At') + 'changed_at' => t('Changed At'), + 'timezone' => t('Timezone') ]; } diff --git a/library/Notifications/View/ScheduleRenderer.php b/library/Notifications/View/ScheduleRenderer.php index 5459392a6..b53e53c1d 100644 --- a/library/Notifications/View/ScheduleRenderer.php +++ b/library/Notifications/View/ScheduleRenderer.php @@ -4,16 +4,11 @@ namespace Icinga\Module\Notifications\View; -use DateTime; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Model\Schedule; -use Icinga\Module\Notifications\Widget\Timeline; -use Icinga\Module\Notifications\Widget\Timeline\Rotation; -use Icinga\Util\Csp; use ipl\Html\Attributes; use ipl\Html\HtmlDocument; use ipl\Web\Common\ItemRenderer; -use ipl\Web\Style; use ipl\Web\Widget\Link; /** @implements ItemRenderer */ @@ -41,23 +36,6 @@ public function assembleTitle($item, HtmlDocument $title, string $layout): void public function assembleCaption($item, HtmlDocument $caption, string $layout): void { - // Number of days is set to 7, since default mode for schedule is week - // and the start day should be the current day - $timeline = (new Timeline($item->id, (new DateTime())->setTime(0, 0), 7)) - ->minimalLayout() - ->setStyle( - (new Style()) - ->setNonce(Csp::getStyleNonce()) - ->setModule('notifications') - ); - - $rotations = $item->rotation->with('timeperiod')->orderBy('first_handoff', SORT_DESC); - - foreach ($rotations as $rotation) { - $timeline->addRotation(new Rotation($rotation)); - } - - $caption->addHtml($timeline); } public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void diff --git a/library/Notifications/Web/Control/TimezonePicker.php b/library/Notifications/Web/Control/TimezonePicker.php new file mode 100644 index 000000000..a0c915ade --- /dev/null +++ b/library/Notifications/Web/Control/TimezonePicker.php @@ -0,0 +1,56 @@ + 'timezone-picker icinga-form inline icinga-controls', + 'name' => 'timezone-picker-form' + ]; + + public function assemble(): void + { + $this->addElement($this->createUidElement()); + + $validTz = []; + foreach (IntlTimeZone::createEnumeration() as $tz) { + try { + if ((new DateTime('now', new DateTimeZone($tz)))->getTimezone()->getLocation()) { + $validTz[$tz] = $tz; + } + } catch (Throwable) { + continue; + } + } + + $this->addElement( + 'select', + static::DEFAULT_TIMEZONE_PARAM, + [ + 'class' => 'autosubmit', + 'label' => $this->translate('Display Timezone'), + 'options' => $validTz + ] + ); + } +} diff --git a/library/Notifications/Widget/Detail/ScheduleDetail.php b/library/Notifications/Widget/Detail/ScheduleDetail.php index 63b0e1692..04d23dc18 100644 --- a/library/Notifications/Widget/Detail/ScheduleDetail.php +++ b/library/Notifications/Widget/Detail/ScheduleDetail.php @@ -4,6 +4,7 @@ namespace Icinga\Module\Notifications\Widget\Detail; +use DateTime; use Icinga\Module\Notifications\Model\Schedule; use Icinga\Module\Notifications\Widget\Detail\ScheduleDetail\Controls; use Icinga\Module\Notifications\Widget\Timeline; @@ -33,6 +34,9 @@ class ScheduleDetail extends BaseHtmlElement /** @var Controls */ protected $controls; + /** @var DateTime The day the timeline should start on */ + protected DateTime $start; + /** @var bool */ private bool $hasRotation = false; @@ -41,11 +45,13 @@ class ScheduleDetail extends BaseHtmlElement * * @param Schedule $schedule * @param Controls $controls + * @param DateTime $start The day the timeline should start on */ - public function __construct(Schedule $schedule, Controls $controls) + public function __construct(Schedule $schedule, Controls $controls, DateTime $start) { $this->schedule = $schedule; $this->controls = $controls; + $this->start = $start; } /** @@ -68,11 +74,7 @@ protected function assembleTimeline(Timeline $timeline): void */ protected function createTimeline(): Timeline { - $timeline = new Timeline( - $this->schedule->id, - $this->controls->getStartDate(), - $this->controls->getNumberOfDays() - ); + $timeline = new Timeline($this->schedule->id, $this->start, $this->controls->getNumberOfDays()); $timeline->setStyle( (new Style()) ->setNonce(Csp::getStyleNonce()) diff --git a/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php b/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php index 398566da3..5dcee2061 100644 --- a/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php +++ b/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php @@ -4,24 +4,25 @@ namespace Icinga\Module\Notifications\Widget\Detail\ScheduleDetail; -use DateTime; use Icinga\Web\Session; use ipl\Html\Attributes; use ipl\Html\Form; use ipl\Html\HtmlElement; use ipl\Html\Text; use ipl\I18n\Translation; +use ipl\Web\Common\FormUid; class Controls extends Form { use Translation; + use FormUid; /** @var string The default mode */ public const DEFAULT_MODE = 'week'; protected $method = 'POST'; - protected $defaultAttributes = ['class' => 'schedule-controls']; + protected $defaultAttributes = ['class' => 'schedule-controls', 'name' => 'schedule-detail-controls-form']; /** * Get the chosen mode @@ -55,16 +56,6 @@ public function getNumberOfDays(): int } } - /** - * Get the start date where the user wants the schedule to begin - * - * @return DateTime - */ - public function getStartDate(): DateTime - { - return (new DateTime())->setTime(0, 0); - } - protected function onSuccess() { Session::getSession()->getNamespace('notifications') @@ -73,6 +64,8 @@ protected function onSuccess() protected function assemble() { + $this->addElement($this->createUidElement()); + $param = 'mode'; $options = [ 'day' => $this->translate('Day'), diff --git a/library/Notifications/Widget/TimeGrid/DaysHeader.php b/library/Notifications/Widget/TimeGrid/DaysHeader.php index 0633fbfa4..23707a700 100644 --- a/library/Notifications/Widget/TimeGrid/DaysHeader.php +++ b/library/Notifications/Widget/TimeGrid/DaysHeader.php @@ -52,13 +52,15 @@ public function assemble(): void $this->translate('Sun', 'sunday') ]; + $displayTimezone = $this->startDay->getTimezone(); $interval = new DateInterval('P1D'); - $today = (new DateTime())->setTime(0, 0); + $today = new DateTime('today', $displayTimezone); $time = clone $this->startDay; $dateFormatter = new IntlDateFormatter( Locale::getDefault(), IntlDateFormatter::MEDIUM, - IntlDateFormatter::NONE + IntlDateFormatter::NONE, + $displayTimezone ); for ($i = 0; $i < $this->days; $i++) { diff --git a/library/Notifications/Widget/Timeline.php b/library/Notifications/Widget/Timeline.php index 4e7e9c111..18e4afa8a 100644 --- a/library/Notifications/Widget/Timeline.php +++ b/library/Notifications/Widget/Timeline.php @@ -186,7 +186,7 @@ public function getEntries(): Traversable foreach ($rotations as $rotation) { $entryFound = false; if (! $this->minimalLayout) { - $flyoutInfo = $rotation->generateEntryInfo(); + $flyoutInfo = $rotation->generateEntryInfo($this->start->getTimezone()); } foreach ($rotation->fetchTimeperiodEntries($this->start, $this->getGrid()->getGridEnd()) as $entry) { @@ -262,6 +262,7 @@ public function getEntries(): Traversable $resultEntry->setUrl($entry->getUrl()); $resultEntry->getAttributes() ->add('data-rotation-position', $entry->getPosition()); + $resultEntry->setScheduleTimezone($entry->getScheduleTimezone()); $resultEntry->setFlyoutContent($entry->getFlyoutContent()) ->calculateAndSetWidthClass($this->getGrid()); } @@ -361,13 +362,16 @@ protected function assemble() ) ); + $displayTimezone = $this->start->getTimezone(); + $dateFormatter = new IntlDateFormatter( Locale::getDefault(), IntlDateFormatter::NONE, - IntlDateFormatter::SHORT + IntlDateFormatter::SHORT, + $displayTimezone ); - $now = new DateTime(); + $now = new DateTime('now', $displayTimezone); $currentTime = new HtmlElement( 'div', new Attributes(['class' => 'time-hand']), diff --git a/library/Notifications/Widget/Timeline/Entry.php b/library/Notifications/Widget/Timeline/Entry.php index 77f3315c1..f94d7b20f 100644 --- a/library/Notifications/Widget/Timeline/Entry.php +++ b/library/Notifications/Widget/Timeline/Entry.php @@ -4,10 +4,11 @@ namespace Icinga\Module\Notifications\Widget\Timeline; +use DateTimeZone; +use Icinga\Module\Notifications\Widget\TimeGrid; use Icinga\Module\Notifications\Widget\TimeGrid\BaseGrid; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; -use Icinga\Module\Notifications\Widget\TimeGrid; use ipl\Html\HtmlElement; use ipl\Html\Text; use ipl\Web\Widget\Icon; @@ -20,6 +21,9 @@ class Entry extends TimeGrid\Entry /** @var ?EntryFlyout Content of the flyoutmenu that is shown when the entry is hovered */ protected ?EntryFlyout $flyoutContent = null; + /** @var ?DateTimeZone The timezone the schedule is created in */ + protected ?DateTimeZone $scheduleTimezone; + /** * @var string A CSS class that changes the placement of the flyout * @@ -70,6 +74,30 @@ public function getFlyoutContent(): ?EntryFlyout return $this->flyoutContent; } + /** + * Set the timezone the schedule is created in + * + * @param DateTimeZone $scheduleTimezone + * + * @return $this + */ + public function setScheduleTimezone(DateTimeZone $scheduleTimezone): static + { + $this->scheduleTimezone = $scheduleTimezone; + + return $this; + } + + /** + * Get the timezone the schedule is created in + * + * @return DateTimeZone|null + */ + public function getScheduleTimezone(): ?DateTimeZone + { + return $this->scheduleTimezone; + } + /** * Set value of $widthClass which will be a CSS class of the rendered entry * @@ -136,7 +164,7 @@ protected function assembleContainer(BaseHtmlElement $container): void ); if (isset($this->flyoutContent)) { - $this->addHtml($this->flyoutContent->withActiveMember($this->getMember())); + $this->addHtml($this->flyoutContent->forEntry($this)); $this->getAttributes()->add('class', $this->getWidthClass()); } } diff --git a/library/Notifications/Widget/Timeline/EntryFlyout.php b/library/Notifications/Widget/Timeline/EntryFlyout.php index 9fa3aecf0..10f725c36 100644 --- a/library/Notifications/Widget/Timeline/EntryFlyout.php +++ b/library/Notifications/Widget/Timeline/EntryFlyout.php @@ -5,6 +5,7 @@ namespace Icinga\Module\Notifications\Widget\Timeline; use DateTime; +use DateTimeZone; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\FormattedString; @@ -43,21 +44,32 @@ class EntryFlyout extends BaseHtmlElement /** @var ?ValidHtml Information about name and mode of the rotation */ protected ?ValidHtml $nameInfo = null; + /** @var DateTimeZone The display timezone */ + protected DateTimeZone $displayTimezone; + /** - * Set active member and return a new instance + * @param DateTimeZone $displayTimezone The display timezone + */ + public function __construct(DateTimeZone $displayTimezone) + { + $this->displayTimezone = $displayTimezone; + } + + /** + * Return a copy of this flyout for the given entry * - * @param Member $member + * @param Entry $entry * * @return static */ - public function withActiveMember(Member $member): static + public function forEntry(Entry $entry): static { if (! isset($this->timeInfo)) { - $this->generateAndSetRotationInfo(); + $this->generateAndSetRotationInfo($entry->getScheduleTimezone()); } $flyout = clone $this; - $flyout->activeMember = $member; + $flyout->activeMember = $entry->getMember(); return $flyout; } @@ -229,12 +241,93 @@ public function assemble(): void ); } + /** + * Shift the weekday if the entry starts on an earlier or later weekday in the display timezone + * + * @param int $day + * @param int $shift + * + * @return int + */ + protected function shiftDay(int $day, int $shift): int + { + return ((($day - 1 + $shift) % 7) + 7) % 7 + 1; + } + + /** + * Shift the whole weekdays array if the entries start on an earlier or later weekday in the display timezone + * + * @param array $days + * @param int $shift + * + * @return array + */ + protected function shiftDays(array $days, int $shift): array + { + if ($shift === 0) { + return $days; + } + + $out = []; + foreach ($days as $d) { + $out[] = $this->shiftDay($d, $shift); + } + + return $out; + } + + /** + * Check whether the passed days are in consecutive order + * + * @param array $days + * + * @return bool + */ + protected function daysAreConsecutiveInOrder(array $days): bool + { + $count = count($days); + + if ($count < 2) { + return false; + } + + for ($i = 0; $i < $count - 1; $i++) { + $expectedNext = ($days[$i] % 7) + 1; + if ($days[$i + 1] != $expectedNext) { + return false; + } + } + + return true; + } + + /** + * Calculate the end date for a multi day rotation using the first handoff and the weekdays for the start and end + * + * @param DateTime $firstHandoff + * @param int $fromDay + * @param int $toDay + * + * @return DateTime + */ + protected function calculateMultiEndDate(DateTime $firstHandoff, int $fromDay, int $toDay): DateTime + { + $durationDays = ($toDay - $fromDay + 7) % 7; + if ($durationDays === 0) { + $durationDays = 7; + } + + return (clone $firstHandoff)->modify("+$durationDays days"); + } + /** * Generate and save the part of the entry flyout, that remains equal for all entries of the rotation * + * @param DateTimeZone $scheduleTimezone The timezone the schedule is created in + * * @return $this */ - protected function generateAndSetRotationInfo(): static + protected function generateAndSetRotationInfo(DateTimeZone $scheduleTimezone): static { $this->setTag('div'); $this->setAttribute('class', 'rotation-info'); @@ -251,9 +344,27 @@ protected function generateAndSetRotationInfo(): static $noneType = \IntlDateFormatter::NONE; $shortType = \IntlDateFormatter::SHORT; - $timeFormatter = new \IntlDateFormatter(\Locale::getDefault(), $noneType, $shortType); - $dateFormatter = new \IntlDateFormatter(\Locale::getDefault(), $shortType, $noneType); - $firstHandoff = $dateFormatter->format(DateTime::createFromFormat('Y-m-d', $this->firstHandoff)); + $startTime = match ($this->mode) { + '24-7' => $this->rotationOptions['at'], + 'partial' => $this->rotationOptions['from'], + 'multi' => $this->rotationOptions['from_at'] + }; + $timeFormatter = new \IntlDateFormatter(\Locale::getDefault(), $noneType, $shortType, $this->displayTimezone); + $dateFormatter = new \IntlDateFormatter(\Locale::getDefault(), $shortType, $noneType, $this->displayTimezone); + + $firstHandoffDt = DateTime::createFromFormat( + 'Y-m-d H:i', + $this->firstHandoff . ' ' . $startTime, + $scheduleTimezone + ); + + $displayFirstHandoffDt = (clone $firstHandoffDt)->setTimezone($this->displayTimezone); + + // Determine whether the first handoff date shifted to the previous day (-1), stayed on the same day (0), + // or moved to the next day (1) after converting to the display timezone. + $shift = $displayFirstHandoffDt->format('Ymd') <=> $firstHandoffDt->format('Ymd'); + + $firstHandoff = $dateFormatter->format($firstHandoffDt); if (($this->rotationOptions['frequency'] ?? null) === 'd') { $handoff = sprintf( @@ -275,19 +386,17 @@ protected function generateAndSetRotationInfo(): static ); } - $startTime = match ($this->mode) { - '24-7' => $this->rotationOptions['at'], - 'partial' => $this->rotationOptions['from'], - 'multi' => $this->rotationOptions['from_at'], - }; - - if (new DateTime() < DateTime::createFromFormat('Y-m-d H:i', $this->firstHandoff . ' ' . $startTime)) { + if (new DateTime('now', $this->displayTimezone) < $displayFirstHandoffDt) { $startText = $this->translate('Starts on %s'); } else { $startText = $this->translate('Started on %s'); } - $startTime = $timeFormatter->format(DateTime::createFromFormat('H:i', $startTime)); + $startTime = $timeFormatter->format(DateTime::createFromFormat( + 'H:i', + $startTime, + $scheduleTimezone + )); $firstHandoffInfo = new HtmlElement( 'span', Attributes::create(['class' => 'rotation-info-start']), @@ -314,9 +423,13 @@ protected function generateAndSetRotationInfo(): static ); if ($this->mode === "partial") { - $days = $this->rotationOptions["days"]; - $to = $timeFormatter->format(DateTime::createFromFormat('H:i', $this->rotationOptions["to"])); - if ($days[count($days) - 1] - $days[0] === (count($days) - 1) && count($days) > 1) { + $days = $this->shiftDays($this->rotationOptions["days"], $shift); + $to = $timeFormatter->format(DateTime::createFromFormat( + 'H:i', + $this->rotationOptions["to"], + $scheduleTimezone + )); + if ($this->daysAreConsecutiveInOrder($days)) { $daysText = sprintf( $this->translate( '%s through %s ', @@ -343,9 +456,29 @@ protected function generateAndSetRotationInfo(): static ) )->addHtml($firstHandoffInfo); } elseif ($this->mode === "multi") { - $fromDay = $weekdayNames[$this->rotationOptions["from_day"]]; - $toDay = $weekdayNames[$this->rotationOptions["to_day"]]; - $toAt = $timeFormatter->format(DateTime::createFromFormat('H:i', $this->rotationOptions["to_at"])); + $firstHandoffEndDt = DateTime::createFromFormat( + 'Y-m-d H:i', + $this->calculateMultiEndDate( + $firstHandoffDt, + $this->rotationOptions["from_day"], + $this->rotationOptions["to_day"] + )->format('Y-m-d') . ' ' . $this->rotationOptions['to_at'], + $scheduleTimezone + ); + + $displayFirstHandoffEndDt = (clone $firstHandoffEndDt)->setTimezone($this->displayTimezone); + + // Determine whether the end day of the first handoff shifted to the previous day (-1), stayed on the + // same day (0), or moved to the next day (1) after converting to the display timezone. + $endShift = $displayFirstHandoffEndDt->format('Ymd') <=> $firstHandoffEndDt->format('Ymd'); + + $fromDay = $weekdayNames[$this->shiftDay($this->rotationOptions["from_day"], $shift)]; + $toDay = $weekdayNames[$this->shiftDay($this->rotationOptions["to_day"], $endShift)]; + $toAt = $timeFormatter->format(DateTime::createFromFormat( + 'H:i', + $this->rotationOptions["to_at"], + $scheduleTimezone + )); $this->timeInfo = $timeInfo->addHtml( new HtmlElement( diff --git a/library/Notifications/Widget/Timeline/Rotation.php b/library/Notifications/Widget/Timeline/Rotation.php index 5265d93c7..0d5d0c9f1 100644 --- a/library/Notifications/Widget/Timeline/Rotation.php +++ b/library/Notifications/Widget/Timeline/Rotation.php @@ -6,6 +6,7 @@ use DateInterval; use DateTime; +use DateTimeZone; use Generator; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Forms\RotationConfigForm; @@ -77,13 +78,13 @@ public function getPriority(): int * * @return EntryFlyout */ - public function generateEntryInfo(): EntryFlyout + public function generateEntryInfo(DateTimeZone $displayTimezone): EntryFlyout { $rotationMembers = iterator_to_array( $this->model->member->with(['contact', 'contactgroup']) ); - $flyout = new EntryFlyout(); + $flyout = new EntryFlyout($displayTimezone); $flyout->setMode($this->model->mode) ->setRotationMembers($rotationMembers) ->setRotationOptions($this->model->options) @@ -103,9 +104,11 @@ public function generateEntryInfo(): EntryFlyout */ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Generator { + $displayTimezone = $after->getTimezone(); + $actualHandoff = null; if (RotationConfigForm::EXPERIMENTAL_OVERRIDES) { - $actualHandoff = $this->model->actual_handoff; + $actualHandoff = $this->model->actual_handoff->setTimezone($displayTimezone); } $entries = $this->model->timeperiod->timeperiod_entry @@ -121,6 +124,9 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera ) )); foreach ($entries as $timeperiodEntry) { + $timeperiodEntry->start_time->setTimezone($displayTimezone); + $timeperiodEntry->end_time->setTimezone($displayTimezone); + if ($timeperiodEntry->member->contact->id !== null) { $member = new Member($timeperiodEntry->member->contact->full_name); } else { @@ -151,8 +157,9 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera $firstHandoff = $timeperiodEntry->start_time; } - $rrule = new RRule($timeperiodEntry->rrule); - $rrule->startAt($firstHandoff); + $rrule = (new RRule($timeperiodEntry->rrule)) + ->setTimezone($timeperiodEntry->timezone) + ->startAt($firstHandoff); $length = $timeperiodEntry->start_time->diff($timeperiodEntry->end_time); $limit = (((int) ceil($after->diff($until)->days / $interval)) + 1) * $limitMultiplier; @@ -171,7 +178,8 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera ->setMember($member) ->setStart($recurrence) ->setEnd($recurrenceEnd) - ->setUrl(Links::rotationSettings($this->getId(), $this->getScheduleId())); + ->setUrl(Links::rotationSettings($this->getId(), $this->getScheduleId())) + ->setScheduleTimezone(new DateTimeZone($timeperiodEntry->timezone)); yield $occurrence; } @@ -180,7 +188,8 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera ->setMember($member) ->setStart($timeperiodEntry->start_time) ->setEnd($timeperiodEntry->end_time) - ->setUrl(Links::rotationSettings($this->getId(), $this->getScheduleId())); + ->setUrl(Links::rotationSettings($this->getId(), $this->getScheduleId())) + ->setScheduleTimezone(new DateTimeZone($timeperiodEntry->timezone)); yield $entry; } diff --git a/library/Notifications/Widget/TimezoneWarning.php b/library/Notifications/Widget/TimezoneWarning.php new file mode 100644 index 000000000..6c90b2178 --- /dev/null +++ b/library/Notifications/Widget/TimezoneWarning.php @@ -0,0 +1,51 @@ + 'timezone-warning']; + + /** @var string The schedule timezone */ + protected string $timezone; + + /** + * @param string $timezone The schedule timezone + */ + public function __construct(string $timezone) + { + $this->timezone = $timezone; + } + + public function assemble(): void + { + $this->addHtml(new Icon('warning')); + $this->addHtml(new HtmlElement( + 'p', + null, + new FormattedString( + $this->translate( + 'The schedule uses the %s timezone. All options you select below are based on this timezone.' + ), + [new HtmlElement('strong', null, new Text($this->timezone))] + ), + )); + } +} diff --git a/public/css/schedule.less b/public/css/schedule.less index bd75e99bb..d5c79362a 100644 --- a/public/css/schedule.less +++ b/public/css/schedule.less @@ -21,6 +21,10 @@ } } } + + .timezone-picker { + margin-left: 1em; + } } .schedule-detail { @@ -65,6 +69,24 @@ } } +.timezone-warning { + display: flex; + align-items: center; + justify-content: center; + column-gap: 1em; + + width: fit-content; + margin: 0 auto 1em auto; + + i.icon::before { + margin-right: 0; + } + + p { + margin: 0; + } +} + /* Design */ .schedule-detail { @@ -91,3 +113,14 @@ padding: .5em; color: @text-color-light; } + +.timezone-warning { + padding: .5em 1em; + border: 1px solid @color-warning; + border-radius: .25em; + + i.icon { + color: @color-warning; + font-size: 1.5em; + } +}