From 7eb27f25146d99f51a73de44ec8b6ad96fba898b Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Mon, 13 Oct 2025 12:04:43 +0200 Subject: [PATCH 01/32] 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 1de36757ce1d5949ab2650bb96aad87316e4d0a6 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Tue, 14 Oct 2025 07:05:03 +0200 Subject: [PATCH 02/32] 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 1ad0885fac62abb9716e274ce8d045ec62ca0f63 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Mon, 13 Oct 2025 14:50:52 +0200 Subject: [PATCH 03/32] Schedule creation form: add timezone dropdown Add dropdown menu to choose schedule timezone. --- .../controllers/ScheduleController.php | 1 + application/forms/ScheduleForm.php | 33 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index df93bc716..061234f34 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -92,6 +92,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 5f3620b1c..4106b504d 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 bool $showRemoveButton = false; + /** @var bool */ + protected bool $showTimezoneDropdown = false; + /** @var Connection */ private Connection $db; @@ -60,6 +64,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,8 +96,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 +195,16 @@ protected function assemble(): void '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 125f32eddb25a03f1fed5e9fde0ea33e94b19699 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:02:30 +0200 Subject: [PATCH 04/32] Add `TimezonePicker` control A dropdown menu to pick a timezone. --- .../Web/Control/TimezonePicker.php | 38 +++++++++++++++++++ public/css/form.less | 9 +++++ 2 files changed, 47 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..bc9ca111c --- /dev/null +++ b/library/Notifications/Web/Control/TimezonePicker.php @@ -0,0 +1,38 @@ + 'timezone-picker']; + + public function assemble(): void + { + $this->addElement( + 'select', + static::DEFAULT_TIMEZONE_PARAM, + [ + 'class' => 'autosubmit', + 'label' => $this->translate('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 6c67b6f99..78e201cde 100644 --- a/public/css/form.less +++ b/public/css/form.less @@ -84,6 +84,15 @@ } } +.timezone-picker { + display: inline; + margin-left: 1em; + + .icinga-controls { + display: inline; + } +} + /* Style */ .icinga-controls { From eedc1ca61cbaebbbe21b97f7cfed60b16c25721a Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:30:08 +0200 Subject: [PATCH 05/32] 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 | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 061234f34..33bbec897 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -10,8 +10,9 @@ 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 ipl\Html\Form; use ipl\Html\Html; use ipl\Stdlib\Filter; @@ -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->addControl($timezonePicker); $this->addControl($scheduleControls); $this->addContent(new ScheduleDetail($schedule, $scheduleControls)); } @@ -205,4 +219,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 1982544ffbb59b46cf50898cf6b2e53704f1e568 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:33:11 +0200 Subject: [PATCH 06/32] 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 2dfbe23a3cda5ca33b54f627f4402c2059db0982 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:36:54 +0200 Subject: [PATCH 07/32] 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 33bbec897..ba71a6de4 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -10,6 +10,7 @@ use Icinga\Module\Notifications\Forms\RotationConfigForm; use Icinga\Module\Notifications\Forms\ScheduleForm; use Icinga\Module\Notifications\Model\Schedule; +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\RecipientSuggestions; @@ -232,6 +233,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 d2e40b7d39def731b98c589807166a259843b006 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:41:58 +0200 Subject: [PATCH 08/32] Display schedule in chosen timezone Makes use of `ScheduleDateTimeFactory`. --- .../Widget/Detail/ScheduleDetail/Controls.php | 3 +- .../Widget/TimeGrid/DaysHeader.php | 6 +- library/Notifications/Widget/Timeline.php | 6 +- .../Widget/Timeline/EntryFlyout.php | 66 +++++++++++++++---- .../Widget/Timeline/Rotation.php | 12 +++- 5 files changed, 74 insertions(+), 19 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 4e7e9c111..b8db329bb 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; @@ -364,10 +365,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/EntryFlyout.php b/library/Notifications/Widget/Timeline/EntryFlyout.php index 9fa3aecf0..be7a6e9b0 100644 --- a/library/Notifications/Widget/Timeline/EntryFlyout.php +++ b/library/Notifications/Widget/Timeline/EntryFlyout.php @@ -5,6 +5,8 @@ namespace Icinga\Module\Notifications\Widget\Timeline; use DateTime; +use DateTimeZone; +use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\FormattedString; @@ -43,6 +45,9 @@ class EntryFlyout extends BaseHtmlElement /** @var ?ValidHtml Information about name and mode of the rotation */ protected ?ValidHtml $nameInfo = null; + /** @var string The schedule timezone */ + protected string $scheduleTimezone; + /** * Set active member and return a new instance * @@ -132,6 +137,20 @@ public function setRotationName(string $rotationName): static return $this; } + /** + * Set the schedule timezone + * + * @param string $scheduleTimezone + * + * @return $this + */ + public function setScheduleTimezone(string $scheduleTimezone): static + { + $this->scheduleTimezone = $scheduleTimezone; + + return $this; + } + public function assemble(): void { if (count($this->rotationMembers) > 1) { @@ -251,9 +270,19 @@ 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)); + $displayTimezone = ScheduleDateTimeFactory::getDisplayTimezone(); + $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, $displayTimezone); + $dateFormatter = new \IntlDateFormatter(\Locale::getDefault(), $shortType, $noneType, $displayTimezone); + $firstHandoff = $dateFormatter->format(DateTime::createFromFormat( + 'Y-m-d H:i', + $this->firstHandoff . ' ' . $startTime, + new DateTimeZone($this->scheduleTimezone) + )); if (($this->rotationOptions['frequency'] ?? null) === 'd') { $handoff = sprintf( @@ -275,19 +304,23 @@ protected function generateAndSetRotationInfo(): static ); } - $startTime = match ($this->mode) { - '24-7' => $this->rotationOptions['at'], - 'partial' => $this->rotationOptions['from'], - 'multi' => $this->rotationOptions['from_at'], - }; + $handoffStart = DateTime::createFromFormat( + 'Y-m-d H:i', + $this->firstHandoff . ' ' . $startTime, + new DateTimeZone($this->scheduleTimezone) + ); - if (new DateTime() < DateTime::createFromFormat('Y-m-d H:i', $this->firstHandoff . ' ' . $startTime)) { + if (new DateTime('now', new DateTimeZone($this->scheduleTimezone)) < $handoffStart) { $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, + new DateTimeZone($this->scheduleTimezone) + )); $firstHandoffInfo = new HtmlElement( 'span', Attributes::create(['class' => 'rotation-info-start']), @@ -315,7 +348,12 @@ protected function generateAndSetRotationInfo(): static if ($this->mode === "partial") { $days = $this->rotationOptions["days"]; - $to = $timeFormatter->format(DateTime::createFromFormat('H:i', $this->rotationOptions["to"])); + + $to = $timeFormatter->format(DateTime::createFromFormat( + 'H:i', + $this->rotationOptions["to"], + new DateTimeZone($this->scheduleTimezone) + )); if ($days[count($days) - 1] - $days[0] === (count($days) - 1) && count($days) > 1) { $daysText = sprintf( $this->translate( @@ -345,7 +383,11 @@ protected function generateAndSetRotationInfo(): static } 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"])); + $toAt = $timeFormatter->format(DateTime::createFromFormat( + 'H:i', + $this->rotationOptions["to_at"], + new DateTimeZone($this->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..1e366f6f2 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\I18n\Translation; use ipl\Scheduler\RRule; use ipl\Stdlib\Filter; @@ -88,7 +90,8 @@ public function generateEntryInfo(): EntryFlyout ->setRotationMembers($rotationMembers) ->setRotationOptions($this->model->options) ->setRotationName($this->model->name) - ->setFirstHandoff($this->model->first_handoff); + ->setFirstHandoff($this->model->first_handoff) + ->setScheduleTimezone($this->model->schedule->execute()->current()->timezone); return $flyout; } @@ -121,6 +124,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 { @@ -139,7 +146,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') ); @@ -157,6 +164,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 1d28930094e59cc87c3d6cbee18effd0247eafb1 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 07:46:33 +0200 Subject: [PATCH 09/32] 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 | 51 +++++++++++++++++++ public/css/schedule.less | 29 +++++++++++ 2 files changed, 80 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..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..4a697a166 100644 --- a/public/css/schedule.less +++ b/public/css/schedule.less @@ -65,6 +65,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 +109,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; + } +} From c9b67935195cae8304da9e29bf504e23d65c3d4d Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 08:05:03 +0200 Subject: [PATCH 10/32] 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 ba71a6de4..2d681a1eb 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -14,6 +14,7 @@ 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 ipl\Html\Form; use ipl\Html\Html; use ipl\Stdlib\Filter; @@ -124,8 +125,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')); @@ -156,9 +164,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(); @@ -221,6 +236,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 d816d8966..cc1fbbb8b 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; /** @@ -123,12 +124,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 f9af3ec773b86c3625999b18fbab692370203dd9 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 08:24:46 +0200 Subject: [PATCH 11/32] 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 8e829bab1..05a8d307a 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; @@ -1226,7 +1228,8 @@ function ($value, $validator) use ($earliestHandoff, $firstHandoff, $latestHando (new \IntlDateFormatter( \Locale::getDefault(), \IntlDateFormatter::MEDIUM, - \IntlDateFormatter::SHORT + \IntlDateFormatter::SHORT, + $this->getScheduleTimezone() ))->format($actualFirstHandoff) ); } @@ -1293,12 +1296,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); } @@ -1698,4 +1702,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 cb81322ce83815577b29540fbe3ec566f5baad8a Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 11:16:58 +0200 Subject: [PATCH 12/32] 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 05a8d307a..0fba24756 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -821,9 +821,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; @@ -832,7 +833,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); From 382436dd2eb63887348778106164dee5c2019090 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 17 Oct 2025 13:25:06 +0200 Subject: [PATCH 13/32] 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 | 35 ++++++++++++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 2d681a1eb..fe0bb535e 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -134,7 +134,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) { @@ -174,7 +174,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 0fba24756..4f5a813c1 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -82,6 +82,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 * @@ -189,12 +192,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; $this->applyDefaultElementDecorators(); } @@ -1320,18 +1324,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 03ff28fb7220cebecb521de9f9eab23c2bef76a8 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Mon, 27 Oct 2025 12:34:02 +0100 Subject: [PATCH 14/32] 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 4f5a813c1..8b6fcfa3f 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -1230,7 +1230,7 @@ function ($value, $validator) use ($earliestHandoff, $firstHandoff, $latestHando if ($actualFirstHandoff < new DateTime()) { return $this->translate('The rotation will start immediately'); } else { - return sprintf( + $handoffHint = sprintf( $this->translate('The rotation will start on %s'), (new \IntlDateFormatter( \Locale::getDefault(), @@ -1239,6 +1239,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 7915a84af85d576bfe23131438846a0d87981106 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Tue, 28 Oct 2025 10:20:51 +0100 Subject: [PATCH 15/32] 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 fe0bb535e..f1a3399ce 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -220,7 +220,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 b8db329bb..290bdcfc6 100644 --- a/library/Notifications/Widget/Timeline.php +++ b/library/Notifications/Widget/Timeline.php @@ -315,7 +315,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 bd0609c6adc3f6aa4b23db029585c438d6ed9b5d Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Tue, 28 Oct 2025 15:24:23 +0100 Subject: [PATCH 16/32] 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 8b6fcfa3f..7c9800fbd 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -233,7 +233,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'); } @@ -264,7 +268,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 */ @@ -458,6 +464,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 1e366f6f2..bcd92b166 100644 --- a/library/Notifications/Widget/Timeline/Rotation.php +++ b/library/Notifications/Widget/Timeline/Rotation.php @@ -106,9 +106,11 @@ public function generateEntryInfo(): EntryFlyout */ 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 @@ -124,7 +126,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); From dd30d8a779a5bcb3326abcff30ea613b8176bb18 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 30 Oct 2025 11:57:42 +0100 Subject: [PATCH 17/32] Use `FormUid` for controls ... ... to prevent the schedule view mode switcher resetting the timezone picker. --- application/controllers/ScheduleController.php | 14 ++++++-------- .../Notifications/Web/Control/TimezonePicker.php | 8 ++++++-- .../Widget/Detail/ScheduleDetail/Controls.php | 6 +++++- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index f1a3399ce..3ff4525bc 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -56,15 +56,13 @@ public function indexAction(): void ->on(Form::ON_SUCCESS, function (ScheduleDetail\Controls $controls) use ($id) { $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); + $defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM; + if ($requestUrl->hasParam($defaultTimezoneParam)) { + $redirectUrl->addParams( + [$defaultTimezoneParam => $requestUrl->getParam($defaultTimezoneParam)] + ); } + $this->redirectNow($redirectUrl); }) ->handleRequest($this->getServerRequest()); diff --git a/library/Notifications/Web/Control/TimezonePicker.php b/library/Notifications/Web/Control/TimezonePicker.php index bc9ca111c..7627fde89 100644 --- a/library/Notifications/Web/Control/TimezonePicker.php +++ b/library/Notifications/Web/Control/TimezonePicker.php @@ -8,6 +8,7 @@ use ipl\Html\Form; use ipl\Html\HtmlElement; use ipl\I18n\Translation; +use ipl\Web\Common\FormUid; /** * A simple dropdown menu to pick a timezone. @@ -15,14 +16,17 @@ class TimezonePicker extends Form { use Translation; + use FormUid; - /** @var string Default timezone param */ + /** @var string Default timezone param */ public const DEFAULT_TIMEZONE_PARAM = 'display_timezone'; - protected $defaultAttributes = ['class' => 'timezone-picker']; + protected $defaultAttributes = ['class' => 'timezone-picker', 'name' => 'timezone-picker-form']; public function assemble(): void { + $this->addElement($this->createUidElement()); + $this->addElement( 'select', static::DEFAULT_TIMEZONE_PARAM, diff --git a/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php b/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php index f1ed32231..39ea79580 100644 --- a/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php +++ b/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php @@ -12,17 +12,19 @@ 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 @@ -74,6 +76,8 @@ protected function onSuccess() protected function assemble() { + $this->addElement($this->createUidElement()); + $param = 'mode'; $options = [ 'day' => $this->translate('Day'), From 9f79515bae121425cb7d2c31e3c0b2bb13a62050 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 30 Oct 2025 12:12:41 +0100 Subject: [PATCH 18/32] Replace `ScheduleDateTimeFactory` ... ... by `ScheduleTimezoneStorage`. This just stores the display and schedule timezone, instead of creating `DateTime` objects. --- .../controllers/ScheduleController.php | 26 +++--- library/Notifications/Common/Links.php | 50 +++++++++--- .../Util/ScheduleDateTimeFactory.php | 66 ---------------- .../Util/ScheduleTimezoneStorage.php | 79 +++++++++++++++++++ .../Widget/Detail/ScheduleDetail/Controls.php | 4 +- .../Widget/TimeGrid/DaysHeader.php | 6 +- library/Notifications/Widget/Timeline.php | 13 ++- .../Widget/Timeline/EntryFlyout.php | 35 +++----- .../Widget/Timeline/Rotation.php | 5 +- 9 files changed, 146 insertions(+), 138 deletions(-) delete mode 100644 library/Notifications/Util/ScheduleDateTimeFactory.php create mode 100644 library/Notifications/Util/ScheduleTimezoneStorage.php diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 3ff4525bc..4d14bcdc8 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -10,7 +10,7 @@ use Icinga\Module\Notifications\Forms\RotationConfigForm; use Icinga\Module\Notifications\Forms\ScheduleForm; use Icinga\Module\Notifications\Model\Schedule; -use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; +use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; use Icinga\Module\Notifications\Web\Control\TimezonePicker; use Icinga\Module\Notifications\Widget\Detail\ScheduleDetail; use Icinga\Module\Notifications\Widget\RecipientSuggestions; @@ -37,6 +37,8 @@ public function indexAction(): void $this->httpNotFound(t('Schedule not found')); } + ScheduleTimezoneStorage::setScheduleTimezone($schedule->timezone); + $this->addTitleTab(sprintf(t('Schedule: %s'), $schedule->name)); $this->controls->addHtml( @@ -54,15 +56,7 @@ public function indexAction(): void ->setAction(Url::fromRequest()->getAbsoluteUrl()) ->populate(['mode' => $this->params->get('mode')]) ->on(Form::ON_SUCCESS, function (ScheduleDetail\Controls $controls) use ($id) { - $redirectUrl = Links::schedule($id)->with(['mode' => $controls->getMode()]); - $requestUrl = Url::fromRequest(); - $defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM; - if ($requestUrl->hasParam($defaultTimezoneParam)) { - $redirectUrl->addParams( - [$defaultTimezoneParam => $requestUrl->getParam($defaultTimezoneParam)] - ); - } - $this->redirectNow($redirectUrl); + $this->redirectNow(Links::schedule($id)->with(['mode' => $controls->getMode()])); }) ->handleRequest($this->getServerRequest()); @@ -123,10 +117,9 @@ 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); + $displayTimezone = $this->params->get('display_timezone') ?? $scheduleTimezone; + $this->setTitle($this->translate('Add Rotation')); if ($displayTimezone !== $scheduleTimezone) { $this->addContent(new TimezoneWarning($scheduleTimezone)); @@ -162,11 +155,10 @@ 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); + $displayTimezone = $this->params->get('display_timezone') ?? $scheduleTimezone; + $this->setTitle($this->translate('Edit Rotation')); if ($displayTimezone !== $scheduleTimezone) { $this->addContent(new TimezoneWarning($scheduleTimezone)); @@ -267,7 +259,7 @@ protected function createTimezonePicker(string $defaultTimezone): TimezonePicker $defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM; $timezoneParam = $this->params->shift($defaultTimezoneParam); - ScheduleDateTimeFactory::setDisplayTimezone($timezoneParam ?? $defaultTimezone); + ScheduleTimezoneStorage::setDisplayTimezone($timezoneParam ?? $defaultTimezone); return (new TimezonePicker()) ->populate([$defaultTimezoneParam => $timezoneParam ?? $defaultTimezone]) diff --git a/library/Notifications/Common/Links.php b/library/Notifications/Common/Links.php index cc1fbbb8b..f40cb1df4 100644 --- a/library/Notifications/Common/Links.php +++ b/library/Notifications/Common/Links.php @@ -4,7 +4,8 @@ namespace Icinga\Module\Notifications\Common; -use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; +use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; +use Icinga\Module\Notifications\Web\Control\TimezonePicker; use ipl\Web\Url; /** @@ -84,7 +85,15 @@ public static function schedules(): Url public static function schedule(int $id): Url { - return Url::fromPath('notifications/schedule', ['id' => $id]); + $redirectUrl = Url::fromPath('notifications/schedule', ['id' => $id]); + + if (ScheduleTimezoneStorage::differ()) { + $redirectUrl->addParams([ + TimezonePicker::DEFAULT_TIMEZONE_PARAM => ScheduleTimezoneStorage::getDisplayTimezone()->getName() + ]); + } + + return $redirectUrl; } public static function scheduleAdd(): Url @@ -124,23 +133,40 @@ public static function contactGroupEdit(int $id): Url public static function rotationAdd(int $scheduleId): Url { - return Url::fromPath('notifications/schedule/add-rotation', [ - 'schedule' => $scheduleId, - 'display_timezone' => ScheduleDateTimeFactory::getDisplayTimezone()->getName() - ]); + $redirectUrl = Url::fromPath('notifications/schedule/add-rotation', ['schedule' => $scheduleId]); + + if (ScheduleTimezoneStorage::differ()) { + $redirectUrl->addParams([ + TimezonePicker::DEFAULT_TIMEZONE_PARAM => ScheduleTimezoneStorage::getDisplayTimezone()->getName() + ]); + } + + return $redirectUrl; } public static function rotationSettings(int $id, int $scheduleId): Url { - return Url::fromPath('notifications/schedule/edit-rotation', [ - 'id' => $id, - 'schedule' => $scheduleId, - 'display_timezone' => ScheduleDateTimeFactory::getDisplayTimezone()->getName() - ]); + $redirectUrl = Url::fromPath('notifications/schedule/edit-rotation', ['id' => $id, 'schedule' => $scheduleId]); + + if (ScheduleTimezoneStorage::differ()) { + $redirectUrl->addParams([ + TimezonePicker::DEFAULT_TIMEZONE_PARAM => ScheduleTimezoneStorage::getDisplayTimezone()->getName() + ]); + } + + return $redirectUrl; } public static function moveRotation(): Url { - return Url::fromPath('notifications/schedule/move-rotation'); + $redirectUrl = Url::fromPath('notifications/schedule/move-rotation'); + + if (ScheduleTimezoneStorage::differ()) { + $redirectUrl->addParams([ + TimezonePicker::DEFAULT_TIMEZONE_PARAM => ScheduleTimezoneStorage::getDisplayTimezone()->getName() + ]); + } + + return $redirectUrl; } } diff --git a/library/Notifications/Util/ScheduleDateTimeFactory.php b/library/Notifications/Util/ScheduleDateTimeFactory.php deleted file mode 100644 index 73ad9a107..000000000 --- a/library/Notifications/Util/ScheduleDateTimeFactory.php +++ /dev/null @@ -1,66 +0,0 @@ -setTimezone(static::getDisplayTimezone()); - } -} diff --git a/library/Notifications/Util/ScheduleTimezoneStorage.php b/library/Notifications/Util/ScheduleTimezoneStorage.php new file mode 100644 index 000000000..9dce9991c --- /dev/null +++ b/library/Notifications/Util/ScheduleTimezoneStorage.php @@ -0,0 +1,79 @@ +getName() !== static::getScheduleTimezone()->getName(); + } +} diff --git a/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php b/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php index 39ea79580..793dfea8e 100644 --- a/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php +++ b/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php @@ -5,7 +5,7 @@ namespace Icinga\Module\Notifications\Widget\Detail\ScheduleDetail; use DateTime; -use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; +use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; use Icinga\Web\Session; use ipl\Html\Attributes; use ipl\Html\Form; @@ -65,7 +65,7 @@ public function getNumberOfDays(): int */ public function getStartDate(): DateTime { - return ScheduleDateTimeFactory::createDateTime()->setTime(0, 0); + return (new DateTime('today', ScheduleTimezoneStorage::getDisplayTimezone())); } protected function onSuccess() diff --git a/library/Notifications/Widget/TimeGrid/DaysHeader.php b/library/Notifications/Widget/TimeGrid/DaysHeader.php index 1f70167c8..23707a700 100644 --- a/library/Notifications/Widget/TimeGrid/DaysHeader.php +++ b/library/Notifications/Widget/TimeGrid/DaysHeader.php @@ -6,7 +6,6 @@ use DateInterval; use DateTime; -use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; use IntlDateFormatter; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; @@ -53,14 +52,15 @@ public function assemble(): void $this->translate('Sun', 'sunday') ]; + $displayTimezone = $this->startDay->getTimezone(); $interval = new DateInterval('P1D'); - $today = ScheduleDateTimeFactory::createDateTime()->setTime(0, 0); + $today = new DateTime('today', $displayTimezone); $time = clone $this->startDay; $dateFormatter = new IntlDateFormatter( Locale::getDefault(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE, - ScheduleDateTimeFactory::getDisplayTimezone() + $displayTimezone ); for ($i = 0; $i < $this->days; $i++) { diff --git a/library/Notifications/Widget/Timeline.php b/library/Notifications/Widget/Timeline.php index 290bdcfc6..9c60bccb6 100644 --- a/library/Notifications/Widget/Timeline.php +++ b/library/Notifications/Widget/Timeline.php @@ -8,7 +8,6 @@ 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; @@ -315,11 +314,7 @@ protected function assembleSidebarEntry(Rotation $rotation): BaseHtmlElement $entry = new HtmlElement('div', Attributes::create(['class' => 'rotation-name'])); $form = new MoveRotationForm(); - $form->setAction( - Links::moveRotation() - ->with(['display_timezone' => ScheduleDateTimeFactory::getDisplayTimezone()->getName()]) - ->getAbsoluteUrl() - ); + $form->setAction(Links::moveRotation()->getAbsoluteUrl()); $form->populate([ 'rotation' => $rotation->getId(), 'priority' => $rotation->getPriority() @@ -366,14 +361,16 @@ protected function assemble() ) ); + $displayTimezone = $this->start->getTimezone(); + $dateFormatter = new IntlDateFormatter( Locale::getDefault(), IntlDateFormatter::NONE, IntlDateFormatter::SHORT, - ScheduleDateTimeFactory::getDisplayTimezone() + $displayTimezone ); - $now = ScheduleDateTimeFactory::createDateTime(); + $now = new DateTime('now', $displayTimezone); $currentTime = new HtmlElement( 'div', new Attributes(['class' => 'time-hand']), diff --git a/library/Notifications/Widget/Timeline/EntryFlyout.php b/library/Notifications/Widget/Timeline/EntryFlyout.php index be7a6e9b0..f6978bf70 100644 --- a/library/Notifications/Widget/Timeline/EntryFlyout.php +++ b/library/Notifications/Widget/Timeline/EntryFlyout.php @@ -5,8 +5,7 @@ namespace Icinga\Module\Notifications\Widget\Timeline; use DateTime; -use DateTimeZone; -use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; +use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\FormattedString; @@ -45,9 +44,6 @@ class EntryFlyout extends BaseHtmlElement /** @var ?ValidHtml Information about name and mode of the rotation */ protected ?ValidHtml $nameInfo = null; - /** @var string The schedule timezone */ - protected string $scheduleTimezone; - /** * Set active member and return a new instance * @@ -137,20 +133,6 @@ public function setRotationName(string $rotationName): static return $this; } - /** - * Set the schedule timezone - * - * @param string $scheduleTimezone - * - * @return $this - */ - public function setScheduleTimezone(string $scheduleTimezone): static - { - $this->scheduleTimezone = $scheduleTimezone; - - return $this; - } - public function assemble(): void { if (count($this->rotationMembers) > 1) { @@ -270,7 +252,8 @@ protected function generateAndSetRotationInfo(): static $noneType = \IntlDateFormatter::NONE; $shortType = \IntlDateFormatter::SHORT; - $displayTimezone = ScheduleDateTimeFactory::getDisplayTimezone(); + $displayTimezone = ScheduleTimezoneStorage::getDisplayTimezone(); + $scheduleTimezone = ScheduleTimezoneStorage::getScheduleTimezone(); $startTime = match ($this->mode) { '24-7' => $this->rotationOptions['at'], 'partial' => $this->rotationOptions['from'], @@ -281,7 +264,7 @@ protected function generateAndSetRotationInfo(): static $firstHandoff = $dateFormatter->format(DateTime::createFromFormat( 'Y-m-d H:i', $this->firstHandoff . ' ' . $startTime, - new DateTimeZone($this->scheduleTimezone) + $scheduleTimezone )); if (($this->rotationOptions['frequency'] ?? null) === 'd') { @@ -307,10 +290,10 @@ protected function generateAndSetRotationInfo(): static $handoffStart = DateTime::createFromFormat( 'Y-m-d H:i', $this->firstHandoff . ' ' . $startTime, - new DateTimeZone($this->scheduleTimezone) + $scheduleTimezone ); - if (new DateTime('now', new DateTimeZone($this->scheduleTimezone)) < $handoffStart) { + if (new DateTime('now', $scheduleTimezone) < $handoffStart) { $startText = $this->translate('Starts on %s'); } else { $startText = $this->translate('Started on %s'); @@ -319,7 +302,7 @@ protected function generateAndSetRotationInfo(): static $startTime = $timeFormatter->format(DateTime::createFromFormat( 'H:i', $startTime, - new DateTimeZone($this->scheduleTimezone) + $scheduleTimezone )); $firstHandoffInfo = new HtmlElement( 'span', @@ -352,7 +335,7 @@ protected function generateAndSetRotationInfo(): static $to = $timeFormatter->format(DateTime::createFromFormat( 'H:i', $this->rotationOptions["to"], - new DateTimeZone($this->scheduleTimezone) + $scheduleTimezone )); if ($days[count($days) - 1] - $days[0] === (count($days) - 1) && count($days) > 1) { $daysText = sprintf( @@ -386,7 +369,7 @@ protected function generateAndSetRotationInfo(): static $toAt = $timeFormatter->format(DateTime::createFromFormat( 'H:i', $this->rotationOptions["to_at"], - new DateTimeZone($this->scheduleTimezone) + $scheduleTimezone )); $this->timeInfo = $timeInfo->addHtml( diff --git a/library/Notifications/Widget/Timeline/Rotation.php b/library/Notifications/Widget/Timeline/Rotation.php index bcd92b166..31f6d9da7 100644 --- a/library/Notifications/Widget/Timeline/Rotation.php +++ b/library/Notifications/Widget/Timeline/Rotation.php @@ -10,7 +10,6 @@ use Generator; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Forms\RotationConfigForm; -use Icinga\Module\Notifications\Util\ScheduleDateTimeFactory; use ipl\I18n\Translation; use ipl\Scheduler\RRule; use ipl\Stdlib\Filter; @@ -90,8 +89,7 @@ public function generateEntryInfo(): EntryFlyout ->setRotationMembers($rotationMembers) ->setRotationOptions($this->model->options) ->setRotationName($this->model->name) - ->setFirstHandoff($this->model->first_handoff) - ->setScheduleTimezone($this->model->schedule->execute()->current()->timezone); + ->setFirstHandoff($this->model->first_handoff); return $flyout; } @@ -165,7 +163,6 @@ 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 7785f40bfb0d5f9c6163efe23b859af510c650e0 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 30 Oct 2025 14:42:46 +0100 Subject: [PATCH 19/32] Use display timezone in rotation ... ... instead using schedule timezone. --- library/Notifications/Widget/Timeline/Rotation.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/library/Notifications/Widget/Timeline/Rotation.php b/library/Notifications/Widget/Timeline/Rotation.php index 31f6d9da7..d4bd914fa 100644 --- a/library/Notifications/Widget/Timeline/Rotation.php +++ b/library/Notifications/Widget/Timeline/Rotation.php @@ -6,7 +6,6 @@ use DateInterval; use DateTime; -use DateTimeZone; use Generator; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Forms\RotationConfigForm; @@ -104,11 +103,11 @@ public function generateEntryInfo(): EntryFlyout */ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Generator { - $scheduleTimezone = new DateTimeZone($this->model->schedule->execute()->current()->timezone); + $displayTimezone = $after->getTimezone(); $actualHandoff = null; if (RotationConfigForm::EXPERIMENTAL_OVERRIDES) { - $actualHandoff = $this->model->actual_handoff->setTimezone($scheduleTimezone); + $actualHandoff = $this->model->actual_handoff->setTimezone($displayTimezone); } $entries = $this->model->timeperiod->timeperiod_entry @@ -124,8 +123,8 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera ) )); foreach ($entries as $timeperiodEntry) { - $timeperiodEntry->start_time->setTimezone($scheduleTimezone); - $timeperiodEntry->end_time->setTimezone($scheduleTimezone); + $timeperiodEntry->start_time->setTimezone($displayTimezone); + $timeperiodEntry->end_time->setTimezone($displayTimezone); if ($timeperiodEntry->member->contact->id !== null) { $member = new Member($timeperiodEntry->member->contact->full_name); @@ -145,7 +144,7 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera } } // TODO: Yearly? (Those unoptimized single occurrences) - $before = (clone $after)->setTimezone($scheduleTimezone)->setTime( + $before = (clone $after)->setTime( (int) $timeperiodEntry->start_time->format('H'), (int) $timeperiodEntry->start_time->format('i') ); @@ -157,7 +156,7 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera $firstHandoff = $timeperiodEntry->start_time; } - $rrule = new RRule($timeperiodEntry->rrule); + $rrule = new RRule($timeperiodEntry->rrule, $displayTimezone->getName()); $rrule->startAt($firstHandoff); $length = $timeperiodEntry->start_time->diff($timeperiodEntry->end_time); From 2e292c9940efde2b5a9970f61339839e9b267c9c Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 30 Oct 2025 15:34:12 +0100 Subject: [PATCH 20/32] Remove extra schedule query in `RotationConfigForm` --- .../controllers/ScheduleController.php | 4 +- application/forms/RotationConfigForm.php | 50 +++++++------------ 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 4d14bcdc8..5dded285f 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -125,7 +125,7 @@ public function addRotationAction(): void $this->addContent(new TimezoneWarning($scheduleTimezone)); } - $form = new RotationConfigForm($scheduleId, Database::get(), $displayTimezone); + $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) { @@ -164,7 +164,7 @@ public function editRotationAction(): void $this->addContent(new TimezoneWarning($scheduleTimezone)); } - $form = new RotationConfigForm($scheduleId, Database::get(), $displayTimezone); + $form = new RotationConfigForm($scheduleId, Database::get(), $displayTimezone, $scheduleTimezone); $form->disableModeSelection(); $form->setShowRemoveButton(); $form->loadRotation($id); diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index 7c9800fbd..c0b7f6666 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -14,7 +14,6 @@ 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; @@ -85,6 +84,9 @@ class RotationConfigForm extends CompatForm /** @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 * @@ -193,12 +195,15 @@ public function hasBeenWiped(): bool * @param int $scheduleId * @param Connection $db * @param string $displayTimezone + * @param string $scheduleTimezone */ - public function __construct(int $scheduleId, Connection $db, string $displayTimezone) + 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(); } @@ -464,8 +469,8 @@ 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()); + $timeperiodEntry->start_time->setTimezone(new DateTimeZone($this->scheduleTimezone)); + $timeperiodEntry->end_time->setTimezone(new DateTimeZone($this->scheduleTimezone)); /** @var TimeperiodEntry $timeperiodEntry */ $rrule = $timeperiodEntry->toRecurrenceRule(); @@ -1245,14 +1250,12 @@ function ($value, $validator) use ($earliestHandoff, $firstHandoff, $latestHando \Locale::getDefault(), \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT, - $this->getScheduleTimezone() + $this->scheduleTimezone ))->format($actualFirstHandoff) ); - $scheduleTimezone = $this->getScheduleTimezone()->getName(); - - if ($this->displayTimezone !== $scheduleTimezone) { - $handoffHint .= sprintf($this->translate(' (in %s)'), $scheduleTimezone); + if ($this->displayTimezone !== $this->scheduleTimezone) { + $handoffHint .= sprintf($this->translate(' (in %s)'), $this->scheduleTimezone); } return $handoffHint; @@ -1320,13 +1323,13 @@ private function parseDateAndTime(?string $date = null, ?string $time = null): D } if (! $format) { - return (new DateTime())->setTimezone($this->getScheduleTimezone())->setTime(0, 0); + return new DateTime('today', new DateTimeZone($this->scheduleTimezone)); } - $datetime = DateTime::createFromFormat($format, $expression, $this->getScheduleTimezone()); + $datetime = DateTime::createFromFormat($format, $expression, new DateTimeZone($this->scheduleTimezone)); if ($datetime === false) { - $datetime = (new DateTime())->setTimezone($this->getScheduleTimezone())->setTime(0, 0); + $datetime = new DateTime('today', $this->scheduleTimezone); } elseif ($time === null) { $datetime->setTime(0, 0); } @@ -1341,13 +1344,11 @@ 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, - $scheduleTimezone->getName() + $this->scheduleTimezone ); $dtzFormatter = new \IntlDateFormatter( @@ -1358,12 +1359,12 @@ private function getTimeOptions(): array ); $options = []; - $dt = new DateTime('now', $scheduleTimezone); + $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); - if ($this->displayTimezone !== $scheduleTimezone->getName()) { + if ($this->displayTimezone !== $this->scheduleTimezone) { $dtzDt = (clone $dt)->setTimezone(new DateTimeZone($this->displayTimezone)); $options[$dt->format('H:i')] = sprintf( @@ -1747,19 +1748,4 @@ 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 85486370652ca15dddac803cdc73f56e12143362 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Mon, 3 Nov 2025 15:35:20 +0100 Subject: [PATCH 21/32] Change timezone source for `TimezonePicker` Some timezones from `DateTimeZone::listIdentifiers()` are not compatible with the `IntlDateFormatter`. So now we get the timezones from `IntlTimeZone::createEnumeration()`. This provides timezones of **type 2 and 3**. Type 2 timezones cause problems, so we want to filter them out. To make sure we only get timezones from **type 3**, we check if they have a location. (Only type 3 timezones have a location). --- .../Notifications/Web/Control/TimezonePicker.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/library/Notifications/Web/Control/TimezonePicker.php b/library/Notifications/Web/Control/TimezonePicker.php index 7627fde89..f4a2712d4 100644 --- a/library/Notifications/Web/Control/TimezonePicker.php +++ b/library/Notifications/Web/Control/TimezonePicker.php @@ -4,11 +4,14 @@ namespace Icinga\Module\Notifications\Web\Control; +use DateTime; use DateTimeZone; +use IntlTimeZone; use ipl\Html\Form; use ipl\Html\HtmlElement; use ipl\I18n\Translation; use ipl\Web\Common\FormUid; +use Throwable; /** * A simple dropdown menu to pick a timezone. @@ -27,13 +30,24 @@ 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' => array_combine(DateTimeZone::listIdentifiers(), DateTimeZone::listIdentifiers()) + 'options' => $validTz ] ); $select = $this->getElement(static::DEFAULT_TIMEZONE_PARAM); From e2c7a278c37cf457a41cfd011b96245caee10a79 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Mon, 3 Nov 2025 16:14:18 +0100 Subject: [PATCH 22/32] Select schedule timezone via suggestion element --- .../controllers/ScheduleController.php | 4 +- application/controllers/SuggestController.php | 35 +++++++++++++ application/forms/ScheduleForm.php | 51 +++++++++++++++---- 3 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 application/controllers/SuggestController.php diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 5dded285f..5df50679c 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -100,8 +100,8 @@ public function addAction(): void { $this->setTitle($this->translate('New Schedule')); $form = (new ScheduleForm(Database::get())) - ->setShowTimezoneDropdown() - ->setAction($this->getRequest()->getUrl()->getAbsoluteUrl()) + ->setShowTimezoneSuggestionInput() + ->setAction($this->getRequest()->getUrl()->setParam('showCompact')->getAbsoluteUrl()) ->on(Form::ON_SUCCESS, function (ScheduleForm $form) { $scheduleId = $form->addSchedule(); 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/ScheduleForm.php b/application/forms/ScheduleForm.php index 4106b504d..3adb7ce85 100644 --- a/application/forms/ScheduleForm.php +++ b/application/forms/ScheduleForm.php @@ -11,14 +11,18 @@ 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 { @@ -31,7 +35,7 @@ class ScheduleForm extends CompatForm protected bool $showRemoveButton = false; /** @var bool */ - protected bool $showTimezoneDropdown = false; + protected bool $showTimezoneSuggestionInput = false; /** @var Connection */ private Connection $db; @@ -71,9 +75,9 @@ public function setShowRemoveButton(bool $state = true): self * * @return $this */ - public function setShowTimezoneDropdown(bool $state = true): self + public function setShowTimezoneSuggestionInput(bool $state = true): self { - $this->showTimezoneDropdown = $state; + $this->showTimezoneSuggestionInput = $state; return $this; } @@ -195,14 +199,39 @@ protected function assemble(): void '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(), - ]); + 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', [ From 79bffe6505f3aeeebf0c55493922c2eeb72ece34 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Wed, 5 Nov 2025 11:20:53 +0100 Subject: [PATCH 23/32] Add first handoff hint in the display timezone Show the first handoff in the display timezone if it differs to the schedule timezone. --- application/forms/RotationConfigForm.php | 31 ++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index c0b7f6666..d36b3b23c 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -1244,7 +1244,7 @@ function ($value, $validator) use ($earliestHandoff, $firstHandoff, $latestHando if ($actualFirstHandoff < new DateTime()) { return $this->translate('The rotation will start immediately'); } else { - $handoffHint = sprintf( + return sprintf( $this->translate('The rotation will start on %s'), (new \IntlDateFormatter( \Locale::getDefault(), @@ -1253,14 +1253,31 @@ function ($value, $validator) use ($earliestHandoff, $firstHandoff, $latestHando $this->scheduleTimezone ))->format($actualFirstHandoff) ); + } + }), + new HtmlElement('br'), + $this->displayTimezone !== $this->scheduleTimezone ? DeferredText::create(function () { + $ruleGenerator = $this->yieldRecurrenceRules(1); + if (! $ruleGenerator->valid()) { + return ''; + } - if ($this->displayTimezone !== $this->scheduleTimezone) { - $handoffHint .= sprintf($this->translate(' (in %s)'), $this->scheduleTimezone); - } - - return $handoffHint; + $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() )); } From 8507064adbd23e7e8b639867571525ca2546f966 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Wed, 5 Nov 2025 11:24:13 +0100 Subject: [PATCH 24/32] Remove times for display timezone In the dropdown menu in the rotation config form from now on don't show times in the display timezone in parentheses next to the normal time (schedule timezone). --- application/forms/RotationConfigForm.php | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index d36b3b23c..28c83afa9 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -1368,30 +1368,12 @@ private function getTimeOptions(): array $this->scheduleTimezone ); - $dtzFormatter = new \IntlDateFormatter( - \Locale::getDefault(), - \IntlDateFormatter::NONE, - \IntlDateFormatter::SHORT, - $this->displayTimezone - ); - $options = []; $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); - - if ($this->displayTimezone !== $this->scheduleTimezone) { - $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); - } + $options[$dt->format('H:i')] = $formatter->format($dt); } } From 61ae8e561f87d43b915c79e123fbcab1a6393f3a Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Wed, 5 Nov 2025 12:20:49 +0100 Subject: [PATCH 25/32] Use session for display timezone Instead of using an url param that has to be preserved by every link, form action or redirect, we now use the 'notifications' session namespace to store the display timezone for the current user. The start day for the timeline now comes from the controller, no longer from the schedule detail controls. --- .../controllers/ScheduleController.php | 56 +++++++++--------- library/Notifications/Common/Links.php | 42 ++------------ .../Util/ScheduleTimezoneStorage.php | 58 +++---------------- .../Web/Control/TimezonePicker.php | 32 ++++++++++ .../Widget/Detail/ScheduleDetail.php | 14 +++-- .../Widget/Detail/ScheduleDetail/Controls.php | 12 ---- .../Widget/Timeline/EntryFlyout.php | 7 ++- 7 files changed, 84 insertions(+), 137 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 5df50679c..da7c59c9b 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -4,6 +4,8 @@ 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; @@ -60,11 +62,15 @@ public function indexAction(): void }) ->handleRequest($this->getServerRequest()); - $timezonePicker = $this->createTimezonePicker($schedule->timezone); + $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->getDisplayTimezone())) + )); } public function settingsAction(): void @@ -118,7 +124,7 @@ public function addRotationAction(): void { $scheduleId = (int) $this->params->getRequired('schedule'); $scheduleTimezone = $this->getScheduleTimezone($scheduleId); - $displayTimezone = $this->params->get('display_timezone') ?? $scheduleTimezone; + $displayTimezone = (new TimezonePicker($scheduleTimezone))->getDisplayTimezone(); $this->setTitle($this->translate('Add Rotation')); if ($displayTimezone !== $scheduleTimezone) { @@ -157,7 +163,7 @@ public function editRotationAction(): void $id = (int) $this->params->getRequired('id'); $scheduleId = (int) $this->params->getRequired('schedule'); $scheduleTimezone = $this->getScheduleTimezone($scheduleId); - $displayTimezone = $this->params->get('display_timezone') ?? $scheduleTimezone; + $displayTimezone = (new TimezonePicker($scheduleTimezone))->getDisplayTimezone(); $this->setTitle($this->translate('Edit Rotation')); if ($displayTimezone !== $scheduleTimezone) { @@ -210,13 +216,7 @@ public function moveRotationAction(): void $form = new MoveRotationForm(Database::get()); $form->on(MoveRotationForm::ON_SUCCESS, function (MoveRotationForm $form) { $this->sendExtraUpdates(['#col1']); - $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); + $this->redirectNow(Links::schedule($form->getScheduleId())); }); $form->handleRequest($this->getServerRequest()); @@ -250,28 +250,24 @@ protected function getScheduleTimezone(int $scheduleId): string /** * Create a timezone picker control * - * @param string $defaultTimezone The default timezone to use if none is set in the request + * @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 $defaultTimezone): TimezonePicker + protected function createTimezonePicker(string $scheduleTimezone, int $scheduleId): TimezonePicker { - $defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM; - $timezoneParam = $this->params->shift($defaultTimezoneParam); - - ScheduleTimezoneStorage::setDisplayTimezone($timezoneParam ?? $defaultTimezone); - - 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()); + return (new TimezonePicker($scheduleTimezone)) + ->populate([ + TimezonePicker::DEFAULT_TIMEZONE_PARAM => $this->params->get(TimezonePicker::DEFAULT_TIMEZONE_PARAM) + ]) + ->on(TimezonePicker::ON_SUBMIT, function (TimezonePicker $timezonePicker) use ($scheduleId) { + $this->redirectNow( + Links::schedule($scheduleId)->with([ + TimezonePicker::DEFAULT_TIMEZONE_PARAM => $timezonePicker->getDisplayTimezone() + ]) + ); + }) + ->handleRequest($this->getServerRequest()); } } diff --git a/library/Notifications/Common/Links.php b/library/Notifications/Common/Links.php index f40cb1df4..d816d8966 100644 --- a/library/Notifications/Common/Links.php +++ b/library/Notifications/Common/Links.php @@ -4,8 +4,6 @@ namespace Icinga\Module\Notifications\Common; -use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; -use Icinga\Module\Notifications\Web\Control\TimezonePicker; use ipl\Web\Url; /** @@ -85,15 +83,7 @@ public static function schedules(): Url public static function schedule(int $id): Url { - $redirectUrl = Url::fromPath('notifications/schedule', ['id' => $id]); - - if (ScheduleTimezoneStorage::differ()) { - $redirectUrl->addParams([ - TimezonePicker::DEFAULT_TIMEZONE_PARAM => ScheduleTimezoneStorage::getDisplayTimezone()->getName() - ]); - } - - return $redirectUrl; + return Url::fromPath('notifications/schedule', ['id' => $id]); } public static function scheduleAdd(): Url @@ -133,40 +123,16 @@ public static function contactGroupEdit(int $id): Url public static function rotationAdd(int $scheduleId): Url { - $redirectUrl = Url::fromPath('notifications/schedule/add-rotation', ['schedule' => $scheduleId]); - - if (ScheduleTimezoneStorage::differ()) { - $redirectUrl->addParams([ - TimezonePicker::DEFAULT_TIMEZONE_PARAM => ScheduleTimezoneStorage::getDisplayTimezone()->getName() - ]); - } - - return $redirectUrl; + return Url::fromPath('notifications/schedule/add-rotation', ['schedule' => $scheduleId]); } public static function rotationSettings(int $id, int $scheduleId): Url { - $redirectUrl = Url::fromPath('notifications/schedule/edit-rotation', ['id' => $id, 'schedule' => $scheduleId]); - - if (ScheduleTimezoneStorage::differ()) { - $redirectUrl->addParams([ - TimezonePicker::DEFAULT_TIMEZONE_PARAM => ScheduleTimezoneStorage::getDisplayTimezone()->getName() - ]); - } - - return $redirectUrl; + return Url::fromPath('notifications/schedule/edit-rotation', ['id' => $id, 'schedule' => $scheduleId]); } public static function moveRotation(): Url { - $redirectUrl = Url::fromPath('notifications/schedule/move-rotation'); - - if (ScheduleTimezoneStorage::differ()) { - $redirectUrl->addParams([ - TimezonePicker::DEFAULT_TIMEZONE_PARAM => ScheduleTimezoneStorage::getDisplayTimezone()->getName() - ]); - } - - return $redirectUrl; + return Url::fromPath('notifications/schedule/move-rotation'); } } diff --git a/library/Notifications/Util/ScheduleTimezoneStorage.php b/library/Notifications/Util/ScheduleTimezoneStorage.php index 9dce9991c..c6a1e6be4 100644 --- a/library/Notifications/Util/ScheduleTimezoneStorage.php +++ b/library/Notifications/Util/ScheduleTimezoneStorage.php @@ -4,76 +4,36 @@ namespace Icinga\Module\Notifications\Util; -use DateTimeZone; +use Icinga\Exception\ProgrammingError; /** * Storage to store display and schedule timezones */ class ScheduleTimezoneStorage { - protected static ?DateTimeZone $displayTimezone; - - protected static ?DateTimeZone $scheduleTimezone; - - /** - * Set the display timezone - * - * @param DateTimeZone|string $timezone The timezone identifier (e.g. 'Europe/Berlin') - * - * @return void - */ - public static function setDisplayTimezone(DateTimeZone|string $timezone): void - { - if ($timezone instanceof DateTimeZone) { - static::$displayTimezone = $timezone; - } else { - static::$displayTimezone = new DateTimeZone($timezone); - } - } - - /** - * Get the display timezone - * - * @return DateTimeZone - */ - public static function getDisplayTimezone(): DateTimeZone - { - return static::$displayTimezone ?? static::getScheduleTimezone(); - } + protected static ?string $scheduleTimezone; /** * Set the schedule timezone * - * @param DateTimeZone|string $timezone The timezone identifier (e.g. 'Europe/Berlin') + * @param string $timezone The timezone identifier (e.g. 'Europe/Berlin') * * @return void */ - public static function setScheduleTimezone(DateTimeZone|string $timezone): void + public static function setScheduleTimezone(string $timezone): void { - if ($timezone instanceof DateTimeZone) { - static::$scheduleTimezone = $timezone; - } else { - static::$scheduleTimezone = new DateTimeZone($timezone); - } + static::$scheduleTimezone = $timezone; } /** * Get the schedule timezone * - * @return DateTimeZone - */ - public static function getScheduleTimezone(): DateTimeZone - { - return static::$scheduleTimezone ?? new DateTimeZone(date_default_timezone_get()); - } - - /** - * Get whether the display and schedule timezones differ + * @return string * - * @return bool Whether the display and schedule timezones differ + * @throws ProgrammingError In case the schedule timezone is not set */ - public static function differ(): bool + public static function getScheduleTimezone(): string { - return static::getDisplayTimezone()->getName() !== static::getScheduleTimezone()->getName(); + return static::$scheduleTimezone ?? throw new ProgrammingError('Schedule timezone has to be set first'); } } diff --git a/library/Notifications/Web/Control/TimezonePicker.php b/library/Notifications/Web/Control/TimezonePicker.php index f4a2712d4..2f7768fb8 100644 --- a/library/Notifications/Web/Control/TimezonePicker.php +++ b/library/Notifications/Web/Control/TimezonePicker.php @@ -6,6 +6,7 @@ use DateTime; use DateTimeZone; +use Icinga\Web\Session; use IntlTimeZone; use ipl\Html\Form; use ipl\Html\HtmlElement; @@ -26,6 +27,36 @@ class TimezonePicker extends Form protected $defaultAttributes = ['class' => 'timezone-picker', 'name' => 'timezone-picker-form']; + protected string $defaultTimezone; + + public function __construct(string $defaultTimezone) + { + $this->defaultTimezone = $defaultTimezone; + } + + /** + * Get the chosen display timezone + * + * @return string + */ + public function getDisplayTimezone(): string + { + return $this->getPopulatedValue(static::DEFAULT_TIMEZONE_PARAM) + ?? Session::getSession()->getNamespace('notifications') + ->get('schedule.display_timezone', $this->defaultTimezone); + } + + /** + * On success store the display timezone in the session + * + * @return void + */ + protected function onSuccess(): void + { + Session::getSession()->getNamespace('notifications') + ->set('schedule.display_timezone', $this->getValue(static::DEFAULT_TIMEZONE_PARAM)); + } + public function assemble(): void { $this->addElement($this->createUidElement()); @@ -52,5 +83,6 @@ public function assemble(): void ); $select = $this->getElement(static::DEFAULT_TIMEZONE_PARAM); $select->prependWrapper(HtmlElement::create('div', ['class' => 'icinga-controls'])); + $select->setValue($this->getDisplayTimezone()); } } 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 793dfea8e..5dcee2061 100644 --- a/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php +++ b/library/Notifications/Widget/Detail/ScheduleDetail/Controls.php @@ -4,8 +4,6 @@ namespace Icinga\Module\Notifications\Widget\Detail\ScheduleDetail; -use DateTime; -use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; use Icinga\Web\Session; use ipl\Html\Attributes; use ipl\Html\Form; @@ -58,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('today', ScheduleTimezoneStorage::getDisplayTimezone())); - } - protected function onSuccess() { Session::getSession()->getNamespace('notifications') diff --git a/library/Notifications/Widget/Timeline/EntryFlyout.php b/library/Notifications/Widget/Timeline/EntryFlyout.php index f6978bf70..fa8bb3a25 100644 --- a/library/Notifications/Widget/Timeline/EntryFlyout.php +++ b/library/Notifications/Widget/Timeline/EntryFlyout.php @@ -5,7 +5,9 @@ namespace Icinga\Module\Notifications\Widget\Timeline; use DateTime; +use DateTimeZone; use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; +use Icinga\Module\Notifications\Web\Control\TimezonePicker; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\FormattedString; @@ -252,8 +254,9 @@ protected function generateAndSetRotationInfo(): static $noneType = \IntlDateFormatter::NONE; $shortType = \IntlDateFormatter::SHORT; - $displayTimezone = ScheduleTimezoneStorage::getDisplayTimezone(); - $scheduleTimezone = ScheduleTimezoneStorage::getScheduleTimezone(); + + $scheduleTimezone = new DateTimeZone(ScheduleTimezoneStorage::getScheduleTimezone()); + $displayTimezone = new DateTimeZone((new TimezonePicker($scheduleTimezone->getName()))->getDisplayTimezone()); $startTime = match ($this->mode) { '24-7' => $this->rotationOptions['at'], 'partial' => $this->rotationOptions['from'], From 62b216b1163f1ff223f95621a4c19204cdee9244 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Wed, 5 Nov 2025 12:29:53 +0100 Subject: [PATCH 26/32] Use `RRule->setTimezone()` Requires https://github.com/Icinga/ipl-scheduler/pull/55 --- library/Notifications/Widget/Timeline/Rotation.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/library/Notifications/Widget/Timeline/Rotation.php b/library/Notifications/Widget/Timeline/Rotation.php index d4bd914fa..d9b1e9b11 100644 --- a/library/Notifications/Widget/Timeline/Rotation.php +++ b/library/Notifications/Widget/Timeline/Rotation.php @@ -9,6 +9,7 @@ use Generator; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Forms\RotationConfigForm; +use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; use ipl\I18n\Translation; use ipl\Scheduler\RRule; use ipl\Stdlib\Filter; @@ -156,8 +157,9 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera $firstHandoff = $timeperiodEntry->start_time; } - $rrule = new RRule($timeperiodEntry->rrule, $displayTimezone->getName()); - $rrule->startAt($firstHandoff); + $rrule = (new RRule($timeperiodEntry->rrule)) + ->setTimezone(ScheduleTimezoneStorage::getScheduleTimezone()) + ->startAt($firstHandoff); $length = $timeperiodEntry->start_time->diff($timeperiodEntry->end_time); $limit = (((int) ceil($after->diff($until)->days / $interval)) + 1) * $limitMultiplier; From b0cef34f7826db929abfbfde2aa0a67436d638ec Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 6 Nov 2025 10:02:34 +0100 Subject: [PATCH 27/32] Fix flyout weekday If the entries shift to another weekday in the display timezone the flyouts have to point this out. For that we shift the days array for **partial** mode rotations or the start and end days for **multi** mode rotations. --- .../Widget/Timeline/EntryFlyout.php | 124 ++++++++++++++++-- 1 file changed, 110 insertions(+), 14 deletions(-) diff --git a/library/Notifications/Widget/Timeline/EntryFlyout.php b/library/Notifications/Widget/Timeline/EntryFlyout.php index fa8bb3a25..a59ed1736 100644 --- a/library/Notifications/Widget/Timeline/EntryFlyout.php +++ b/library/Notifications/Widget/Timeline/EntryFlyout.php @@ -232,6 +232,85 @@ 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 * @@ -264,11 +343,19 @@ protected function generateAndSetRotationInfo(): static }; $timeFormatter = new \IntlDateFormatter(\Locale::getDefault(), $noneType, $shortType, $displayTimezone); $dateFormatter = new \IntlDateFormatter(\Locale::getDefault(), $shortType, $noneType, $displayTimezone); - $firstHandoff = $dateFormatter->format(DateTime::createFromFormat( + $firstHandoffDt = DateTime::createFromFormat( 'Y-m-d H:i', $this->firstHandoff . ' ' . $startTime, $scheduleTimezone - )); + ); + + $displayFirstHandoffDt = (clone $firstHandoffDt)->setTimezone($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( @@ -290,13 +377,7 @@ protected function generateAndSetRotationInfo(): static ); } - $handoffStart = DateTime::createFromFormat( - 'Y-m-d H:i', - $this->firstHandoff . ' ' . $startTime, - $scheduleTimezone - ); - - if (new DateTime('now', $scheduleTimezone) < $handoffStart) { + if (new DateTime('now', $displayTimezone) < $displayFirstHandoffDt) { $startText = $this->translate('Starts on %s'); } else { $startText = $this->translate('Started on %s'); @@ -333,14 +414,13 @@ protected function generateAndSetRotationInfo(): static ); if ($this->mode === "partial") { - $days = $this->rotationOptions["days"]; - + $days = $this->shiftDays($this->rotationOptions["days"], $shift); $to = $timeFormatter->format(DateTime::createFromFormat( 'H:i', $this->rotationOptions["to"], $scheduleTimezone )); - if ($days[count($days) - 1] - $days[0] === (count($days) - 1) && count($days) > 1) { + if ($this->daysAreConsecutiveInOrder($days)) { $daysText = sprintf( $this->translate( '%s through %s ', @@ -367,8 +447,24 @@ protected function generateAndSetRotationInfo(): static ) )->addHtml($firstHandoffInfo); } elseif ($this->mode === "multi") { - $fromDay = $weekdayNames[$this->rotationOptions["from_day"]]; - $toDay = $weekdayNames[$this->rotationOptions["to_day"]]; + $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($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"], From bf12f9f76a55265d04ebec724278e9757b23b365 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 6 Nov 2025 15:25:21 +0100 Subject: [PATCH 28/32] Change the way flyouts get timezones The schedule timezone is set on the entry and then passed to `FlyoutEntry::generateAndSetRotationInfo()` as argument. The display timezone is set via the constructor. --- library/Notifications/Widget/Timeline.php | 3 +- .../Notifications/Widget/Timeline/Entry.php | 32 ++++++++++++++- .../Widget/Timeline/EntryFlyout.php | 41 +++++++++++-------- .../Widget/Timeline/Rotation.php | 11 +++-- 4 files changed, 64 insertions(+), 23 deletions(-) diff --git a/library/Notifications/Widget/Timeline.php b/library/Notifications/Widget/Timeline.php index 9c60bccb6..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()); } 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 a59ed1736..10f725c36 100644 --- a/library/Notifications/Widget/Timeline/EntryFlyout.php +++ b/library/Notifications/Widget/Timeline/EntryFlyout.php @@ -6,8 +6,6 @@ use DateTime; use DateTimeZone; -use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; -use Icinga\Module\Notifications\Web\Control\TimezonePicker; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\FormattedString; @@ -46,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; } @@ -314,9 +323,11 @@ protected function calculateMultiEndDate(DateTime $firstHandoff, int $fromDay, i /** * 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'); @@ -333,23 +344,21 @@ protected function generateAndSetRotationInfo(): static $noneType = \IntlDateFormatter::NONE; $shortType = \IntlDateFormatter::SHORT; - - $scheduleTimezone = new DateTimeZone(ScheduleTimezoneStorage::getScheduleTimezone()); - $displayTimezone = new DateTimeZone((new TimezonePicker($scheduleTimezone->getName()))->getDisplayTimezone()); $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, $displayTimezone); - $dateFormatter = new \IntlDateFormatter(\Locale::getDefault(), $shortType, $noneType, $displayTimezone); + $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($displayTimezone); + $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. @@ -377,7 +386,7 @@ protected function generateAndSetRotationInfo(): static ); } - if (new DateTime('now', $displayTimezone) < $displayFirstHandoffDt) { + if (new DateTime('now', $this->displayTimezone) < $displayFirstHandoffDt) { $startText = $this->translate('Starts on %s'); } else { $startText = $this->translate('Started on %s'); @@ -457,7 +466,7 @@ protected function generateAndSetRotationInfo(): static $scheduleTimezone ); - $displayFirstHandoffEndDt = (clone $firstHandoffEndDt)->setTimezone($displayTimezone); + $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. diff --git a/library/Notifications/Widget/Timeline/Rotation.php b/library/Notifications/Widget/Timeline/Rotation.php index d9b1e9b11..3eccb970b 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; @@ -78,13 +79,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) @@ -178,7 +179,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; } @@ -187,7 +189,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; } From 8146c8c981562eab7a7c39ea505f2e8ce0724178 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 6 Nov 2025 15:28:35 +0100 Subject: [PATCH 29/32] Use timeperiod entry timezone for `RRule` --- library/Notifications/Widget/Timeline/Rotation.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/library/Notifications/Widget/Timeline/Rotation.php b/library/Notifications/Widget/Timeline/Rotation.php index 3eccb970b..0d5d0c9f1 100644 --- a/library/Notifications/Widget/Timeline/Rotation.php +++ b/library/Notifications/Widget/Timeline/Rotation.php @@ -10,7 +10,6 @@ use Generator; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Forms\RotationConfigForm; -use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; use ipl\I18n\Translation; use ipl\Scheduler\RRule; use ipl\Stdlib\Filter; @@ -159,7 +158,7 @@ public function fetchTimeperiodEntries(DateTime $after, DateTime $until): Genera } $rrule = (new RRule($timeperiodEntry->rrule)) - ->setTimezone(ScheduleTimezoneStorage::getScheduleTimezone()) + ->setTimezone($timeperiodEntry->timezone) ->startAt($firstHandoff); $length = $timeperiodEntry->start_time->diff($timeperiodEntry->end_time); From b62d1b2490a7867b24653f660f8084fed727ec33 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 6 Nov 2025 15:29:50 +0100 Subject: [PATCH 30/32] Remove `ScheduleTimezoneStorage` Because it's nowhere used anymore. --- .../controllers/ScheduleController.php | 3 -- .../Util/ScheduleTimezoneStorage.php | 39 ------------------- 2 files changed, 42 deletions(-) delete mode 100644 library/Notifications/Util/ScheduleTimezoneStorage.php diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index da7c59c9b..66e8b97b0 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -12,7 +12,6 @@ use Icinga\Module\Notifications\Forms\RotationConfigForm; use Icinga\Module\Notifications\Forms\ScheduleForm; use Icinga\Module\Notifications\Model\Schedule; -use Icinga\Module\Notifications\Util\ScheduleTimezoneStorage; use Icinga\Module\Notifications\Web\Control\TimezonePicker; use Icinga\Module\Notifications\Widget\Detail\ScheduleDetail; use Icinga\Module\Notifications\Widget\RecipientSuggestions; @@ -39,8 +38,6 @@ public function indexAction(): void $this->httpNotFound(t('Schedule not found')); } - ScheduleTimezoneStorage::setScheduleTimezone($schedule->timezone); - $this->addTitleTab(sprintf(t('Schedule: %s'), $schedule->name)); $this->controls->addHtml( diff --git a/library/Notifications/Util/ScheduleTimezoneStorage.php b/library/Notifications/Util/ScheduleTimezoneStorage.php deleted file mode 100644 index c6a1e6be4..000000000 --- a/library/Notifications/Util/ScheduleTimezoneStorage.php +++ /dev/null @@ -1,39 +0,0 @@ - Date: Thu, 6 Nov 2025 16:17:48 +0100 Subject: [PATCH 31/32] Fix `TimezonePicker` css classes --- library/Notifications/Web/Control/TimezonePicker.php | 7 ++++--- public/css/form.less | 9 --------- public/css/schedule.less | 4 ++++ 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/library/Notifications/Web/Control/TimezonePicker.php b/library/Notifications/Web/Control/TimezonePicker.php index 2f7768fb8..6404676ca 100644 --- a/library/Notifications/Web/Control/TimezonePicker.php +++ b/library/Notifications/Web/Control/TimezonePicker.php @@ -9,7 +9,6 @@ use Icinga\Web\Session; use IntlTimeZone; use ipl\Html\Form; -use ipl\Html\HtmlElement; use ipl\I18n\Translation; use ipl\Web\Common\FormUid; use Throwable; @@ -25,7 +24,10 @@ class TimezonePicker extends Form /** @var string Default timezone param */ public const DEFAULT_TIMEZONE_PARAM = 'display_timezone'; - protected $defaultAttributes = ['class' => 'timezone-picker', 'name' => 'timezone-picker-form']; + protected $defaultAttributes = [ + 'class' => 'timezone-picker icinga-form inline icinga-controls', + 'name' => 'timezone-picker-form' + ]; protected string $defaultTimezone; @@ -82,7 +84,6 @@ public function assemble(): void ] ); $select = $this->getElement(static::DEFAULT_TIMEZONE_PARAM); - $select->prependWrapper(HtmlElement::create('div', ['class' => 'icinga-controls'])); $select->setValue($this->getDisplayTimezone()); } } diff --git a/public/css/form.less b/public/css/form.less index 78e201cde..6c67b6f99 100644 --- a/public/css/form.less +++ b/public/css/form.less @@ -84,15 +84,6 @@ } } -.timezone-picker { - display: inline; - margin-left: 1em; - - .icinga-controls { - display: inline; - } -} - /* Style */ .icinga-controls { diff --git a/public/css/schedule.less b/public/css/schedule.less index 4a697a166..d5c79362a 100644 --- a/public/css/schedule.less +++ b/public/css/schedule.less @@ -21,6 +21,10 @@ } } } + + .timezone-picker { + margin-left: 1em; + } } .schedule-detail { From d98851701eb68cca5564a78a487389cac7d651a5 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 6 Nov 2025 16:44:17 +0100 Subject: [PATCH 32/32] Move session handling to `ScheduleController` Instead of handling the session in `TimezonePicker`. --- .../controllers/ScheduleController.php | 56 +++++++++++++++---- .../Web/Control/TimezonePicker.php | 33 ----------- 2 files changed, 44 insertions(+), 45 deletions(-) diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 66e8b97b0..b6b60cc11 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -16,6 +16,7 @@ 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; @@ -66,7 +67,10 @@ public function indexAction(): void $this->addContent(new ScheduleDetail( $schedule, $scheduleControls, - new DateTime('today', new DateTimeZone($timezonePicker->getDisplayTimezone())) + new DateTime('today', new DateTimeZone( + $timezonePicker->getValue(TimezonePicker::DEFAULT_TIMEZONE_PARAM) + ?? $this->getDisplayTimezoneFromSession($schedule->timezone) + )), )); } @@ -121,7 +125,7 @@ public function addRotationAction(): void { $scheduleId = (int) $this->params->getRequired('schedule'); $scheduleTimezone = $this->getScheduleTimezone($scheduleId); - $displayTimezone = (new TimezonePicker($scheduleTimezone))->getDisplayTimezone(); + $displayTimezone = $this->getDisplayTimezoneFromSession($scheduleTimezone); $this->setTitle($this->translate('Add Rotation')); if ($displayTimezone !== $scheduleTimezone) { @@ -160,7 +164,7 @@ public function editRotationAction(): void $id = (int) $this->params->getRequired('id'); $scheduleId = (int) $this->params->getRequired('schedule'); $scheduleTimezone = $this->getScheduleTimezone($scheduleId); - $displayTimezone = (new TimezonePicker($scheduleTimezone))->getDisplayTimezone(); + $displayTimezone = $this->getDisplayTimezoneFromSession($scheduleTimezone); $this->setTitle($this->translate('Edit Rotation')); if ($displayTimezone !== $scheduleTimezone) { @@ -254,17 +258,45 @@ protected function getScheduleTimezone(int $scheduleId): string */ protected function createTimezonePicker(string $scheduleTimezone, int $scheduleId): TimezonePicker { - return (new TimezonePicker($scheduleTimezone)) + $defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM; + return (new TimezonePicker()) ->populate([ - TimezonePicker::DEFAULT_TIMEZONE_PARAM => $this->params->get(TimezonePicker::DEFAULT_TIMEZONE_PARAM) + $defaultTimezoneParam => $this->params->get($defaultTimezoneParam) + ?? $this->getDisplayTimezoneFromSession($scheduleTimezone) ]) - ->on(TimezonePicker::ON_SUBMIT, function (TimezonePicker $timezonePicker) use ($scheduleId) { - $this->redirectNow( - Links::schedule($scheduleId)->with([ - TimezonePicker::DEFAULT_TIMEZONE_PARAM => $timezonePicker->getDisplayTimezone() - ]) - ); - }) + ->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/library/Notifications/Web/Control/TimezonePicker.php b/library/Notifications/Web/Control/TimezonePicker.php index 6404676ca..a0c915ade 100644 --- a/library/Notifications/Web/Control/TimezonePicker.php +++ b/library/Notifications/Web/Control/TimezonePicker.php @@ -6,7 +6,6 @@ use DateTime; use DateTimeZone; -use Icinga\Web\Session; use IntlTimeZone; use ipl\Html\Form; use ipl\I18n\Translation; @@ -29,36 +28,6 @@ class TimezonePicker extends Form 'name' => 'timezone-picker-form' ]; - protected string $defaultTimezone; - - public function __construct(string $defaultTimezone) - { - $this->defaultTimezone = $defaultTimezone; - } - - /** - * Get the chosen display timezone - * - * @return string - */ - public function getDisplayTimezone(): string - { - return $this->getPopulatedValue(static::DEFAULT_TIMEZONE_PARAM) - ?? Session::getSession()->getNamespace('notifications') - ->get('schedule.display_timezone', $this->defaultTimezone); - } - - /** - * On success store the display timezone in the session - * - * @return void - */ - protected function onSuccess(): void - { - Session::getSession()->getNamespace('notifications') - ->set('schedule.display_timezone', $this->getValue(static::DEFAULT_TIMEZONE_PARAM)); - } - public function assemble(): void { $this->addElement($this->createUidElement()); @@ -83,7 +52,5 @@ public function assemble(): void 'options' => $validTz ] ); - $select = $this->getElement(static::DEFAULT_TIMEZONE_PARAM); - $select->setValue($this->getDisplayTimezone()); } }