From 463af37171291dbbfad5d8212ce32d71b857a0f3 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Mon, 13 Oct 2025 12:04:43 +0200 Subject: [PATCH 01/16] Remove timeline from schedule list item --- .../controllers/SchedulesController.php | 10 --------- .../Notifications/View/ScheduleRenderer.php | 22 ------------------- 2 files changed, 32 deletions(-) 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/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 From efc813242de975753e9f66ae2112d41658f66779 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Tue, 14 Oct 2025 07:05:03 +0200 Subject: [PATCH 02/16] Add timezone column to schedule model --- library/Notifications/Model/Schedule.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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') ]; } From f375b5b0408bc602abed91bfdc2655fed2d03610 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Mon, 13 Oct 2025 14:50:52 +0200 Subject: [PATCH 03/16] Schedule creation form: add timezone dropdown Add dropdown menu to choose schedule timezone. --- .../controllers/ScheduleController.php | 1 + application/forms/ScheduleForm.php | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 90b84ac6c..f3e3c9d0f 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -91,6 +91,7 @@ public function addAction(): void { $this->setTitle($this->translate('New Schedule')); $form = (new ScheduleForm(Database::get())) + ->setShowTimezoneDropdown() ->setAction($this->getRequest()->getUrl()->getAbsoluteUrl()) ->on(Form::ON_SUCCESS, function (ScheduleForm $form) { $scheduleId = $form->addSchedule(); diff --git a/application/forms/ScheduleForm.php b/application/forms/ScheduleForm.php index e3782e48c..d75ae8f2a 100644 --- a/application/forms/ScheduleForm.php +++ b/application/forms/ScheduleForm.php @@ -5,6 +5,7 @@ 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; @@ -29,6 +30,9 @@ class ScheduleForm extends CompatForm /** @var bool */ protected $showRemoveButton = false; + /** @var bool */ + protected $showTimezoneDropdown = false; + /** @var Connection */ private $db; @@ -59,6 +63,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 setShowTimezoneDropdown(bool $state = true): self + { + $this->showTimezoneDropdown = $state; + + return $this; + } + public function hasBeenRemoved(): bool { $btn = $this->getPressedSubmitElement(); @@ -78,7 +96,8 @@ 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") + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'timezone' => $this->getValue('timezone') ]); return $db->lastInsertId(); @@ -175,6 +194,16 @@ protected function assemble() 'placeholder' => $this->translate('e.g. working hours, on call, etc ...') ]); + if ($this->showTimezoneDropdown) { + $this->addElement('select', 'timezone', [ + 'required' => true, + 'label' => $this->translate('Schedule Timezone'), + 'description' => $this->translate('Select the time zone in which this schedule operates.'), + 'multiOptions' => array_combine(DateTimeZone::listIdentifiers(), DateTimeZone::listIdentifiers()), + 'value' => date_default_timezone_get(), + ]); + } + $this->addElement('submit', 'submit', [ 'label' => $this->getSubmitLabel() ]); From c08ae3433d1e59dba28029ad1a0fa9b144a77c79 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:02:30 +0200 Subject: [PATCH 04/16] Add `TimezonePicker` control A dropdown menu to pick a timezone. --- .../Web/Control/TimezonePicker.php | 35 +++++++++++++++++++ public/css/form.less | 9 +++++ 2 files changed, 44 insertions(+) create mode 100644 library/Notifications/Web/Control/TimezonePicker.php diff --git a/library/Notifications/Web/Control/TimezonePicker.php b/library/Notifications/Web/Control/TimezonePicker.php new file mode 100644 index 000000000..762dda400 --- /dev/null +++ b/library/Notifications/Web/Control/TimezonePicker.php @@ -0,0 +1,35 @@ + 'timezone-picker']; + + public function assemble(): void + { + $this->addElement( + 'select', + static::DEFAULT_TIMEZONE_PARAM, + [ + 'class' => 'autosubmit', + 'label' => 'Display Timezone', + 'options' => array_combine(DateTimeZone::listIdentifiers(), DateTimeZone::listIdentifiers()) + ] + ); + $select = $this->getElement(static::DEFAULT_TIMEZONE_PARAM); + $select->prependWrapper(HtmlElement::create('div', ['class' => 'icinga-controls'])); + } +} diff --git a/public/css/form.less b/public/css/form.less index de5e13163..86455e519 100644 --- a/public/css/form.less +++ b/public/css/form.less @@ -88,6 +88,15 @@ } } +.timezone-picker { + display: inline; + margin-left: 1em; + + .icinga-controls { + display: inline; + } +} + /* Style */ .icinga-controls { From b08660fc81a1a9799990aabcf976d4f779c1b9ca Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:30:08 +0200 Subject: [PATCH 05/16] Add `TimezonePicker` to `ScheduleController` To change the timezone to display the schedule in. Per default the timezone the schedule is created in is used. --- .../controllers/ScheduleController.php | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index f3e3c9d0f..2dcc52792 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -11,6 +11,7 @@ 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 ipl\Html\Form; use ipl\Html\Html; @@ -51,10 +52,23 @@ public function indexAction(): void ->setAction(Url::fromRequest()->getAbsoluteUrl()) ->populate(['mode' => $this->params->get('mode')]) ->on(Form::ON_SUCCESS, function (ScheduleDetail\Controls $controls) use ($id) { - $this->redirectNow(Links::schedule($id)->with(['mode' => $controls->getMode()])); + $redirectUrl = Links::schedule($id)->with(['mode' => $controls->getMode()]); + $requestUrl = Url::fromRequest(); + if ($requestUrl->getParam('mode') !== $controls->getValue('mode')) { + $defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM; + if ($requestUrl->hasParam($defaultTimezoneParam)) { + $redirectUrl->addParams( + [$defaultTimezoneParam => $requestUrl->getParam($defaultTimezoneParam)] + ); + } + $this->redirectNow($redirectUrl); + } }) ->handleRequest($this->getServerRequest()); + $timezonePicker = $this->createTimezonePicker($schedule->timezone); + $this->controls->addHtml($timezonePicker); + $this->addContent(new ScheduleDetail($schedule, $scheduleControls)); } @@ -204,4 +218,30 @@ public function suggestRecipientAction(): void $this->getDocument()->addHtml($suggestions); } + + /** + * Create a timezone picker control + * + * @param string $defaultTimezone The default timezone to use if none is set in the request + * + * @return TimezonePicker The timezone picker control + */ + protected function createTimezonePicker(string $defaultTimezone): TimezonePicker + { + $defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM; + $timezoneParam = $this->params->shift($defaultTimezoneParam); + + return (new TimezonePicker()) + ->populate([$defaultTimezoneParam => $timezoneParam ?? $defaultTimezone]) + ->on( + TimezonePicker::ON_SUBMIT, + function (TimezonePicker $timezonePicker) use ($defaultTimezoneParam) { + $requestUrl = Url::fromRequest(); + $pickedTimezone = $timezonePicker->getValue($defaultTimezoneParam); + if ($requestUrl->getParam($defaultTimezoneParam) !== $pickedTimezone) { + $this->redirectNow($requestUrl->with([$defaultTimezoneParam => $pickedTimezone])); + } + } + )->handleRequest($this->getServerRequest()); + } } From 7d9cadec01266623dd490221da03420cc901cb2c Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:33:11 +0200 Subject: [PATCH 06/16] Add `ScheduleDateTimeFactory` `ScheduleDateTimeFactory` is a factory class to create DateTime objects in a specific timezone that is set in a static attribute. --- .../Util/ScheduleDateTimeFactory.php | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 library/Notifications/Util/ScheduleDateTimeFactory.php diff --git a/library/Notifications/Util/ScheduleDateTimeFactory.php b/library/Notifications/Util/ScheduleDateTimeFactory.php new file mode 100644 index 000000000..73ad9a107 --- /dev/null +++ b/library/Notifications/Util/ScheduleDateTimeFactory.php @@ -0,0 +1,66 @@ +setTimezone(static::getDisplayTimezone()); + } +} From c8186c986336e74ad110edbb8475b46f1dd6ce37 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:36:54 +0200 Subject: [PATCH 07/16] Set timezone for `ScheduleDateTimeFactory` Use the chosen value from the timezone picker control to set the timezone attribute in `ScheduleDateTimeFactory`. --- application/controllers/ScheduleController.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 2dcc52792..e8d49179c 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -11,6 +11,7 @@ use Icinga\Module\Notifications\Forms\ScheduleForm; use Icinga\Module\Notifications\Model\Schedule; use Icinga\Module\Notifications\Widget\RecipientSuggestions; +use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; use Icinga\Module\Notifications\Web\Control\TimezonePicker; use Icinga\Module\Notifications\Widget\Detail\ScheduleDetail; use ipl\Html\Form; @@ -231,6 +232,8 @@ protected function createTimezonePicker(string $defaultTimezone): TimezonePicker $defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM; $timezoneParam = $this->params->shift($defaultTimezoneParam); + ScheduleDateTimeFactory::setDisplayTimezone($timezoneParam ?? $defaultTimezone); + return (new TimezonePicker()) ->populate([$defaultTimezoneParam => $timezoneParam ?? $defaultTimezone]) ->on( From a49e3515819052097845143e708ead6f38a70167 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:41:58 +0200 Subject: [PATCH 08/16] Display schedule in chosen timezone Makes use of `ScheduleDateTimeFactory`. --- .../Widget/Detail/ScheduleDetail/Controls.php | 3 ++- library/Notifications/Widget/TimeGrid/DaysHeader.php | 6 ++++-- library/Notifications/Widget/Timeline.php | 6 ++++-- library/Notifications/Widget/Timeline/Entry.php | 10 ++++++++-- library/Notifications/Widget/Timeline/Rotation.php | 9 ++++++++- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php b/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php index 398566da3..f1ed32231 100644 --- a/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php +++ b/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php @@ -5,6 +5,7 @@ namespace Icinga\Module\Notifications\Widget\Detail\ScheduleDetail; use DateTime; +use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; use Icinga\Web\Session; use ipl\Html\Attributes; use ipl\Html\Form; @@ -62,7 +63,7 @@ public function getNumberOfDays(): int */ public function getStartDate(): DateTime { - return (new DateTime())->setTime(0, 0); + return ScheduleDateTimeFactory::createDateTime()->setTime(0, 0); } protected function onSuccess() diff --git a/library/Notifications/Widget/TimeGrid/DaysHeader.php b/library/Notifications/Widget/TimeGrid/DaysHeader.php index 0633fbfa4..1f70167c8 100644 --- a/library/Notifications/Widget/TimeGrid/DaysHeader.php +++ b/library/Notifications/Widget/TimeGrid/DaysHeader.php @@ -6,6 +6,7 @@ use DateInterval; use DateTime; +use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; use IntlDateFormatter; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; @@ -53,12 +54,13 @@ public function assemble(): void ]; $interval = new DateInterval('P1D'); - $today = (new DateTime())->setTime(0, 0); + $today = ScheduleDateTimeFactory::createDateTime()->setTime(0, 0); $time = clone $this->startDay; $dateFormatter = new IntlDateFormatter( Locale::getDefault(), IntlDateFormatter::MEDIUM, - IntlDateFormatter::NONE + IntlDateFormatter::NONE, + ScheduleDateTimeFactory::getDisplayTimezone() ); for ($i = 0; $i < $this->days; $i++) { diff --git a/library/Notifications/Widget/Timeline.php b/library/Notifications/Widget/Timeline.php index 9990593ab..6a1bd1972 100644 --- a/library/Notifications/Widget/Timeline.php +++ b/library/Notifications/Widget/Timeline.php @@ -8,6 +8,7 @@ use DateTime; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Forms\MoveRotationForm; +use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; use Icinga\Module\Notifications\Widget\TimeGrid\DynamicGrid; use Icinga\Module\Notifications\Widget\TimeGrid\EntryProvider; use Icinga\Module\Notifications\Widget\TimeGrid\GridStep; @@ -356,10 +357,11 @@ protected function assemble() $dateFormatter = new IntlDateFormatter( Locale::getDefault(), IntlDateFormatter::NONE, - IntlDateFormatter::SHORT + IntlDateFormatter::SHORT, + ScheduleDateTimeFactory::getDisplayTimezone() ); - $now = new DateTime(); + $now = ScheduleDateTimeFactory::createDateTime(); $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 859bb605b..00bcb163f 100644 --- a/library/Notifications/Widget/Timeline/Entry.php +++ b/library/Notifications/Widget/Timeline/Entry.php @@ -4,9 +4,10 @@ namespace Icinga\Module\Notifications\Widget\Timeline; +use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; +use Icinga\Module\Notifications\Widget\TimeGrid; 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; @@ -57,7 +58,12 @@ protected function assembleContainer(BaseHtmlElement $container): void $dateType = \IntlDateFormatter::SHORT; } - $formatter = new \IntlDateFormatter(\Locale::getDefault(), $dateType, $timeType); + $formatter = new \IntlDateFormatter( + \Locale::getDefault(), + $dateType, + $timeType, + ScheduleDateTimeFactory::getDisplayTimezone() + ); $container->addAttributes([ 'title' => sprintf( diff --git a/library/Notifications/Widget/Timeline/Rotation.php b/library/Notifications/Widget/Timeline/Rotation.php index 536dbc063..cb2ada18e 100644 --- a/library/Notifications/Widget/Timeline/Rotation.php +++ b/library/Notifications/Widget/Timeline/Rotation.php @@ -6,9 +6,11 @@ use DateInterval; use DateTime; +use DateTimeZone; use Generator; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Forms\RotationConfigForm; +use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; use ipl\Scheduler\RRule; use ipl\Stdlib\Filter; use Recurr\Frequency; @@ -97,6 +99,10 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera ) )); foreach ($entries as $timeperiodEntry) { + $scheduleTimezone = new DateTimeZone($this->model->schedule->execute()->current()->timezone); + $timeperiodEntry->start_time->setTimezone($scheduleTimezone); + $timeperiodEntry->end_time->setTimezone($scheduleTimezone); + if ($timeperiodEntry->member->contact->id !== null) { $member = new Member($timeperiodEntry->member->contact->full_name); } else { @@ -115,7 +121,7 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera } } // TODO: Yearly? (Those unoptimized single occurrences) - $before = (clone $after)->setTime( + $before = (clone $after)->setTimezone($scheduleTimezone)->setTime( (int) $timeperiodEntry->start_time->format('H'), (int) $timeperiodEntry->start_time->format('i') ); @@ -133,6 +139,7 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera $length = $timeperiodEntry->start_time->diff($timeperiodEntry->end_time); $limit = (((int) ceil($after->diff($until)->days / $interval)) + 1) * $limitMultiplier; foreach ($rrule->getNextRecurrences($firstHandoff, $limit) as $recurrence) { + $recurrence = ScheduleDateTimeFactory::createDateTimeFromTimestamp($recurrence->getTimestamp()); $recurrenceEnd = (clone $recurrence)->add($length); if ($recurrence < $actualHandoff && $recurrenceEnd > $actualHandoff) { $recurrence = $actualHandoff; From a941805d26c82f69eb3f60ae0072fc0efa6ddd33 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:46:33 +0200 Subject: [PATCH 09/16] Add `TimezoneWarning` A Widget that represents a warning if the display timezone differs from the schedule timezone. It's used for the forms to add and edit rotations. --- .../Notifications/Widget/TimezoneWarning.php | 38 +++++++++++++++++++ public/css/schedule.less | 29 ++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 library/Notifications/Widget/TimezoneWarning.php diff --git a/library/Notifications/Widget/TimezoneWarning.php b/library/Notifications/Widget/TimezoneWarning.php new file mode 100644 index 000000000..504b219a6 --- /dev/null +++ b/library/Notifications/Widget/TimezoneWarning.php @@ -0,0 +1,38 @@ + 'timezone-warning'] + ) { + } + + public function assemble(): void + { + $this->addHtml(new Icon('warning')); + $this->addHtml(new HtmlElement( + 'p', + null, + new Text($this->translate('The schedule\'s timezone is ')), + new HtmlElement('strong', null, new Text($this->timezone)) + )); + } +} diff --git a/public/css/schedule.less b/public/css/schedule.less index 01a5a1797..c497257cb 100644 --- a/public/css/schedule.less +++ b/public/css/schedule.less @@ -67,6 +67,25 @@ } } +.timezone-warning { + display: flex; + align-items: center; + justify-content: center; + column-gap: 1em; + + width: fit-content; + margin: 0 auto 1em auto; + padding: .5em 1em; + + i.icon:before { + margin-right: 0; + } + + p { + margin: 0; + } +} + /* Design */ .schedule-detail .entry.highlighted { @@ -85,3 +104,13 @@ padding: .5em; color: @text-color-light; } + +.timezone-warning { + border: 1px solid @color-warning; + border-radius: .25em; + + i.icon { + color: @color-warning; + font-size: 1.5em; + } +} From 6bf325c7acbf5280067e45adc118ad6f5ccb3f9d Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 08:05:03 +0200 Subject: [PATCH 10/16] Add `TimezoneWarning` to the modals... ...if the display timezone differs from the schedule timezone. --- .../controllers/ScheduleController.php | 30 +++++++++++++++++++ library/Notifications/Common/Links.php | 12 ++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index e8d49179c..8a84d9701 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -14,6 +14,7 @@ use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; use Icinga\Module\Notifications\Web\Control\TimezonePicker; use Icinga\Module\Notifications\Widget\Detail\ScheduleDetail; +use Icinga\Module\Notifications\Widget\TimezoneWarning; use ipl\Html\Form; use ipl\Html\Html; use ipl\Stdlib\Filter; @@ -123,8 +124,15 @@ public function addAction(): void public function addRotationAction(): void { $scheduleId = (int) $this->params->getRequired('schedule'); + $displayTimezone = $this->params->get('display_timezone'); $this->setTitle($this->translate('Add Rotation')); + $scheduleTimezone = $this->getScheduleTimezone($scheduleId); + + if ($displayTimezone !== $scheduleTimezone) { + $this->addContent(new TimezoneWarning($scheduleTimezone)); + } + $form = new RotationConfigForm($scheduleId, Database::get()); $form->setAction($this->getRequest()->getUrl()->setParam('showCompact')->getAbsoluteUrl()); $form->setSuggestionUrl(Url::fromPath('notifications/schedule/suggest-recipient')); @@ -155,9 +163,16 @@ public function addRotationAction(): void public function editRotationAction(): void { $id = (int) $this->params->getRequired('id'); + $displayTimezone = $this->params->get('display_timezone'); $scheduleId = (int) $this->params->getRequired('schedule'); $this->setTitle($this->translate('Edit Rotation')); + $scheduleTimezone = $this->getScheduleTimezone($scheduleId); + + if ($displayTimezone !== $scheduleTimezone) { + $this->addContent(new TimezoneWarning($scheduleTimezone)); + } + $form = new RotationConfigForm($scheduleId, Database::get()); $form->disableModeSelection(); $form->setShowRemoveButton(); @@ -220,6 +235,21 @@ 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 * diff --git a/library/Notifications/Common/Links.php b/library/Notifications/Common/Links.php index 3773f8589..de23859cc 100644 --- a/library/Notifications/Common/Links.php +++ b/library/Notifications/Common/Links.php @@ -4,6 +4,7 @@ namespace Icinga\Module\Notifications\Common; +use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; use ipl\Web\Url; /** @@ -118,12 +119,19 @@ public static function contactGroupEdit(int $id): Url public static function rotationAdd(int $scheduleId): Url { - return Url::fromPath('notifications/schedule/add-rotation', ['schedule' => $scheduleId]); + return Url::fromPath('notifications/schedule/add-rotation', [ + 'schedule' => $scheduleId, + 'display_timezone' => ScheduleDateTimeFactory::getDisplayTimezone()->getName() + ]); } public static function rotationSettings(int $id, int $scheduleId): Url { - return Url::fromPath('notifications/schedule/edit-rotation', ['id' => $id, 'schedule' => $scheduleId]); + return Url::fromPath('notifications/schedule/edit-rotation', [ + 'id' => $id, + 'schedule' => $scheduleId, + 'display_timezone' => ScheduleDateTimeFactory::getDisplayTimezone()->getName() + ]); } public static function moveRotation(): Url From d1dd20be531a7844efe6713b10e6f7f7957c3517 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 08:24:46 +0200 Subject: [PATCH 11/16] Use schedule timezone for `RotationConfigForm` Use the schedule timezone to configure rotations. All times entered are handled in the schedule timezone. --- application/forms/RotationConfigForm.php | 27 ++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index b6cf9b8f7..fb9854379 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; @@ -13,6 +14,7 @@ use Icinga\Module\Notifications\Model\Contact; use Icinga\Module\Notifications\Model\Contactgroup; use Icinga\Module\Notifications\Model\Rotation; +use Icinga\Module\Notifications\Model\Schedule; use Icinga\Module\Notifications\Model\TimeperiodEntry; use Icinga\Util\Json; use Icinga\Web\Session; @@ -1145,7 +1147,8 @@ function ($value, $validator) use ($earliestHandoff, $firstHandoff, $latestHando (new \IntlDateFormatter( \Locale::getDefault(), \IntlDateFormatter::MEDIUM, - \IntlDateFormatter::SHORT + \IntlDateFormatter::SHORT, + $this->getScheduleTimezone() ))->format($actualFirstHandoff) ); } @@ -1267,12 +1270,13 @@ private function parseDateAndTime(?string $date = null, ?string $time = null): D } if (! $format) { - return (new DateTime())->setTime(0, 0); + return (new DateTime())->setTimezone($this->getScheduleTimezone())->setTime(0, 0); } - $datetime = DateTime::createFromFormat($format, $expression); + $datetime = DateTime::createFromFormat($format, $expression, $this->getScheduleTimezone()); + if ($datetime === false) { - $datetime = (new DateTime())->setTime(0, 0); + $datetime = (new DateTime())->setTimezone($this->getScheduleTimezone())->setTime(0, 0); } elseif ($time === null) { $datetime->setTime(0, 0); } @@ -1665,4 +1669,19 @@ public function hasChanges(): bool return ! empty(array_udiff_assoc($values, $dbValuesToCompare, $checker)); } + + /** + * Get the timezone of the schedule + * + * @return DateTimeZone The schedule timezone + */ + protected function getScheduleTimezone(): DateTimeZone + { + return new DateTimeZone( + Schedule::on(Database::get()) + ->filter(Filter::equal('id', $this->scheduleId)) + ->first() + ->timezone + ); + } } From 9f11eba002c75875475d1b36b26ae0394ba87b01 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 11:16:58 +0200 Subject: [PATCH 12/16] Add times for the next day to own option group This is because we want to remove the parentheses for future changes where those should be used to display a time in another timezone. --- application/forms/RotationConfigForm.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index fb9854379..e1c1f434b 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -802,9 +802,10 @@ protected function assemblePartialDayOptions(FieldsetElement $options): DateTime ]); $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; @@ -813,7 +814,9 @@ protected function assemblePartialDayOptions(FieldsetElement $options): DateTime $to = $options->createElement('select', 'to', [ 'required' => true, - 'options' => $timeOptions + 'options' => empty($timeOptions) + ? ['Next Day' => $nextDayTimeOptions] + : ['Today' => $timeOptions, 'Next Day' => $nextDayTimeOptions] ]); $options->registerElement($to); From 9f4dfd113eb337d258a41921acdd4f14866893a3 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 13:25:06 +0200 Subject: [PATCH 13/16] Show times for display timezone In the dropdown menu in the rotation config form show times in the display timezone in parentheses next to the normal time (schedule timezone). --- .../controllers/ScheduleController.php | 4 +-- application/forms/RotationConfigForm.php | 34 ++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 8a84d9701..1fedae39a 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -133,7 +133,7 @@ public function addRotationAction(): void $this->addContent(new TimezoneWarning($scheduleTimezone)); } - $form = new RotationConfigForm($scheduleId, Database::get()); + $form = new RotationConfigForm($scheduleId, Database::get(), $displayTimezone); $form->setAction($this->getRequest()->getUrl()->setParam('showCompact')->getAbsoluteUrl()); $form->setSuggestionUrl(Url::fromPath('notifications/schedule/suggest-recipient')); $form->on(RotationConfigForm::ON_SENT, function ($form) { @@ -173,7 +173,7 @@ public function editRotationAction(): void $this->addContent(new TimezoneWarning($scheduleTimezone)); } - $form = new RotationConfigForm($scheduleId, Database::get()); + $form = new RotationConfigForm($scheduleId, Database::get(), $displayTimezone); $form->disableModeSelection(); $form->setShowRemoveButton(); $form->loadRotation($id); diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index e1c1f434b..d1d60bbc1 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -80,6 +80,9 @@ class RotationConfigForm extends CompatForm /** @var int The rotation id */ protected $rotationId; + /** @var string The timezone to display the timeline in */ + protected $displayTimezone; + /** * Set the label for the submit button * @@ -187,11 +190,13 @@ public function hasBeenWiped(): bool * * @param int $scheduleId * @param Connection $db + * @param string $displayTimezone */ - public function __construct(int $scheduleId, Connection $db) + public function __construct(int $scheduleId, Connection $db, string $displayTimezone) { $this->db = $db; $this->scheduleId = $scheduleId; + $this->displayTimezone = $displayTimezone; } /** @@ -1294,18 +1299,39 @@ private function parseDateAndTime(?string $date = null, ?string $time = null): D */ private function getTimeOptions(): array { + $scheduleTimezone = $this->getScheduleTimezone(); + $formatter = new \IntlDateFormatter( \Locale::getDefault(), \IntlDateFormatter::NONE, - \IntlDateFormatter::SHORT + \IntlDateFormatter::SHORT, + $scheduleTimezone->getName() + ); + + $dtzFormatter = new \IntlDateFormatter( + \Locale::getDefault(), + \IntlDateFormatter::NONE, + \IntlDateFormatter::SHORT, + $this->displayTimezone ); $options = []; - $dt = new DateTime(); + $dt = new DateTime('now', $scheduleTimezone); for ($hour = 0; $hour < 24; $hour++) { for ($minute = 0; $minute < 60; $minute += 30) { $dt->setTime($hour, $minute); - $options[$dt->format('H:i')] = $formatter->format($dt); + + if ($this->displayTimezone !== $scheduleTimezone->getName()) { + $dtzDt = (clone $dt)->setTimezone(new DateTimeZone($this->displayTimezone)); + + $options[$dt->format('H:i')] = sprintf( + '%s (%s)', + $formatter->format($dt), + $dtzFormatter->format($dtzDt) + ); + } else { + $options[$dt->format('H:i')] = $formatter->format($dt); + } } } From d02f5b84a26ce190b1ddc87601a49c8b69381634 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Mon, 27 Oct 2025 12:34:02 +0100 Subject: [PATCH 14/16] Show schedule timezone in first handoff hint ... ... if the display timezone differs. --- application/forms/RotationConfigForm.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index d1d60bbc1..62fb5d651 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -1150,7 +1150,7 @@ function ($value, $validator) use ($earliestHandoff, $firstHandoff, $latestHando if ($actualFirstHandoff < new DateTime()) { return $this->translate('The first handoff will happen immediately'); } else { - return sprintf( + $handoffHint = sprintf( $this->translate('The first handoff will happen on %s'), (new \IntlDateFormatter( \Locale::getDefault(), @@ -1159,6 +1159,14 @@ function ($value, $validator) use ($earliestHandoff, $firstHandoff, $latestHando $this->getScheduleTimezone() ))->format($actualFirstHandoff) ); + + $scheduleTimezone = $this->getScheduleTimezone()->getName(); + + if ($this->displayTimezone !== $scheduleTimezone) { + $handoffHint .= sprintf($this->translate(' (in %s)'), $scheduleTimezone); + } + + return $handoffHint; } }) )); From 76863d7dc4fc0981deef69891e14cd404bb0633c Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Tue, 28 Oct 2025 10:20:51 +0100 Subject: [PATCH 15/16] Keep display timezone on drag & drop --- application/controllers/ScheduleController.php | 8 +++++++- library/Notifications/Widget/Timeline.php | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 1fedae39a..3679436eb 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -219,7 +219,13 @@ public function moveRotationAction(): void $form = new MoveRotationForm(Database::get()); $form->on(MoveRotationForm::ON_SUCCESS, function (MoveRotationForm $form) { $this->sendExtraUpdates(['#col1']); - $this->redirectNow(Links::schedule($form->getScheduleId())); + $requestUrl = Url::fromRequest(); + $redirectUrl = Links::schedule($form->getScheduleId()); + $defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM; + if ($requestUrl->hasParam($defaultTimezoneParam)) { + $redirectUrl->addParams([$defaultTimezoneParam => $requestUrl->getParam($defaultTimezoneParam)]); + } + $this->redirectNow($redirectUrl); }); $form->handleRequest($this->getServerRequest()); diff --git a/library/Notifications/Widget/Timeline.php b/library/Notifications/Widget/Timeline.php index 6a1bd1972..d32434225 100644 --- a/library/Notifications/Widget/Timeline.php +++ b/library/Notifications/Widget/Timeline.php @@ -307,7 +307,11 @@ protected function assembleSidebarEntry(Rotation $rotation): BaseHtmlElement $entry = new HtmlElement('div', Attributes::create(['class' => 'rotation-name'])); $form = new MoveRotationForm(); - $form->setAction(Links::moveRotation()->getAbsoluteUrl()); + $form->setAction( + Links::moveRotation() + ->with(['display_timezone' => ScheduleDateTimeFactory::getDisplayTimezone()->getName()]) + ->getAbsoluteUrl() + ); $form->populate([ 'rotation' => $rotation->getId(), 'priority' => $rotation->getPriority() From bb75f8d62b2075617b1e356d02f1ffa1de11452f Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Tue, 28 Oct 2025 15:24:23 +0100 Subject: [PATCH 16/16] Adjust timezones for experimental parts --- application/forms/RotationConfigForm.php | 13 +++++++++++-- library/Notifications/Widget/Timeline/Rotation.php | 5 +++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index 62fb5d651..ac3a500f0 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -230,7 +230,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->displayTimezone) + ); if ($handoff === false) { throw new ConfigurationError('Invalid date format'); } @@ -261,7 +265,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->displayTimezone) + ); } /** @var ?Rotation $newerRotation */ @@ -455,6 +461,9 @@ public function editRotation(int $rotationId): void ->filter(Filter::equal('timeperiod.owned_by_rotation_id', $rotationId)); foreach ($timeperiodEntries as $timeperiodEntry) { + $timeperiodEntry->start_time->setTimezone($this->getScheduleTimezone()); + $timeperiodEntry->end_time->setTimezone($this->getScheduleTimezone()); + /** @var TimeperiodEntry $timeperiodEntry */ $rrule = $timeperiodEntry->toRecurrenceRule(); $shiftDuration = $timeperiodEntry->start_time->diff($timeperiodEntry->end_time); diff --git a/library/Notifications/Widget/Timeline/Rotation.php b/library/Notifications/Widget/Timeline/Rotation.php index cb2ada18e..8f2d3f73b 100644 --- a/library/Notifications/Widget/Timeline/Rotation.php +++ b/library/Notifications/Widget/Timeline/Rotation.php @@ -81,9 +81,11 @@ public function getPriority(): int */ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Generator { + $scheduleTimezone = new DateTimeZone($this->model->schedule->execute()->current()->timezone); + $actualHandoff = null; if (RotationConfigForm::EXPERIMENTAL_OVERRIDES) { - $actualHandoff = $this->model->actual_handoff; + $actualHandoff = $this->model->actual_handoff->setTimezone($scheduleTimezone); } $entries = $this->model->timeperiod->timeperiod_entry @@ -99,7 +101,6 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera ) )); foreach ($entries as $timeperiodEntry) { - $scheduleTimezone = new DateTimeZone($this->model->schedule->execute()->current()->timezone); $timeperiodEntry->start_time->setTimezone($scheduleTimezone); $timeperiodEntry->end_time->setTimezone($scheduleTimezone);