Skip to content

Commit 34beeb2

Browse files
authored
Proper timezone handling for schedules (#369)
2 parents 7d1378b + d988517 commit 34beeb2

File tree

17 files changed

+627
-108
lines changed

17 files changed

+627
-108
lines changed

application/controllers/ScheduleController.php

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@
44

55
namespace Icinga\Module\Notifications\Controllers;
66

7+
use DateTime;
8+
use DateTimeZone;
79
use Icinga\Module\Notifications\Common\Database;
810
use Icinga\Module\Notifications\Common\Links;
911
use Icinga\Module\Notifications\Forms\MoveRotationForm;
1012
use Icinga\Module\Notifications\Forms\RotationConfigForm;
1113
use Icinga\Module\Notifications\Forms\ScheduleForm;
1214
use Icinga\Module\Notifications\Model\Schedule;
13-
use Icinga\Module\Notifications\Widget\RecipientSuggestions;
15+
use Icinga\Module\Notifications\Web\Control\TimezonePicker;
1416
use Icinga\Module\Notifications\Widget\Detail\ScheduleDetail;
17+
use Icinga\Module\Notifications\Widget\RecipientSuggestions;
18+
use Icinga\Module\Notifications\Widget\TimezoneWarning;
19+
use Icinga\Web\Session;
1520
use ipl\Html\Form;
1621
use ipl\Html\Html;
1722
use ipl\Stdlib\Filter;
@@ -55,8 +60,18 @@ public function indexAction(): void
5560
})
5661
->handleRequest($this->getServerRequest());
5762

63+
$timezonePicker = $this->createTimezonePicker($schedule->timezone, $id);
64+
65+
$this->addControl($timezonePicker);
5866
$this->addControl($scheduleControls);
59-
$this->addContent(new ScheduleDetail($schedule, $scheduleControls));
67+
$this->addContent(new ScheduleDetail(
68+
$schedule,
69+
$scheduleControls,
70+
new DateTime('today', new DateTimeZone(
71+
$timezonePicker->getValue(TimezonePicker::DEFAULT_TIMEZONE_PARAM)
72+
?? $this->getDisplayTimezoneFromSession($schedule->timezone)
73+
)),
74+
));
6075
}
6176

6277
public function settingsAction(): void
@@ -92,7 +107,8 @@ public function addAction(): void
92107
{
93108
$this->setTitle($this->translate('New Schedule'));
94109
$form = (new ScheduleForm(Database::get()))
95-
->setAction($this->getRequest()->getUrl()->getAbsoluteUrl())
110+
->setShowTimezoneSuggestionInput()
111+
->setAction($this->getRequest()->getUrl()->setParam('showCompact')->getAbsoluteUrl())
96112
->on(Form::ON_SUCCESS, function (ScheduleForm $form) {
97113
$scheduleId = $form->addSchedule();
98114

@@ -108,9 +124,15 @@ public function addAction(): void
108124
public function addRotationAction(): void
109125
{
110126
$scheduleId = (int) $this->params->getRequired('schedule');
127+
$scheduleTimezone = $this->getScheduleTimezone($scheduleId);
128+
$displayTimezone = $this->getDisplayTimezoneFromSession($scheduleTimezone);
111129
$this->setTitle($this->translate('Add Rotation'));
112130

113-
$form = new RotationConfigForm($scheduleId, Database::get());
131+
if ($displayTimezone !== $scheduleTimezone) {
132+
$this->addContent(new TimezoneWarning($scheduleTimezone));
133+
}
134+
135+
$form = new RotationConfigForm($scheduleId, Database::get(), $displayTimezone, $scheduleTimezone);
114136
$form->setAction($this->getRequest()->getUrl()->setParam('showCompact')->getAbsoluteUrl());
115137
$form->setSuggestionUrl(Url::fromPath('notifications/schedule/suggest-recipient'));
116138
$form->on(RotationConfigForm::ON_SENT, function ($form) {
@@ -141,9 +163,15 @@ public function editRotationAction(): void
141163
{
142164
$id = (int) $this->params->getRequired('id');
143165
$scheduleId = (int) $this->params->getRequired('schedule');
166+
$scheduleTimezone = $this->getScheduleTimezone($scheduleId);
167+
$displayTimezone = $this->getDisplayTimezoneFromSession($scheduleTimezone);
144168
$this->setTitle($this->translate('Edit Rotation'));
145169

146-
$form = new RotationConfigForm($scheduleId, Database::get());
170+
if ($displayTimezone !== $scheduleTimezone) {
171+
$this->addContent(new TimezoneWarning($scheduleTimezone));
172+
}
173+
174+
$form = new RotationConfigForm($scheduleId, Database::get(), $displayTimezone, $scheduleTimezone);
147175
$form->disableModeSelection();
148176
$form->setShowRemoveButton();
149177
$form->loadRotation($id);
@@ -204,4 +232,71 @@ public function suggestRecipientAction(): void
204232

205233
$this->getDocument()->addHtml($suggestions);
206234
}
235+
236+
/**
237+
* Get the timezone of a schedule
238+
*
239+
* @param int $scheduleId The ID of the schedule
240+
*
241+
* @return string The timezone of the schedule
242+
*/
243+
protected function getScheduleTimezone(int $scheduleId): string
244+
{
245+
return Schedule::on(Database::get())
246+
->filter(Filter::equal('schedule.id', $scheduleId))
247+
->first()
248+
->timezone;
249+
}
250+
251+
/**
252+
* Create a timezone picker control
253+
*
254+
* @param string $scheduleTimezone The schedule timezone is used as default if no timezone is in the session
255+
* @param int $scheduleId The schedule id
256+
*
257+
* @return TimezonePicker The timezone picker control
258+
*/
259+
protected function createTimezonePicker(string $scheduleTimezone, int $scheduleId): TimezonePicker
260+
{
261+
$defaultTimezoneParam = TimezonePicker::DEFAULT_TIMEZONE_PARAM;
262+
return (new TimezonePicker())
263+
->populate([
264+
$defaultTimezoneParam => $this->params->get($defaultTimezoneParam)
265+
?? $this->getDisplayTimezoneFromSession($scheduleTimezone)
266+
])
267+
->on(
268+
TimezonePicker::ON_SUBMIT,
269+
function (TimezonePicker $timezonePicker) use ($defaultTimezoneParam, $scheduleId, $scheduleTimezone) {
270+
$this->writeDisplayTimezoneToSession($timezonePicker->getValue($defaultTimezoneParam));
271+
$this->redirectNow(Links::schedule($scheduleId)->with([
272+
$defaultTimezoneParam => $timezonePicker->getValue($defaultTimezoneParam)
273+
]));
274+
}
275+
)
276+
->handleRequest($this->getServerRequest());
277+
}
278+
279+
/**
280+
* Get the display timezone from the session
281+
*
282+
* @param string|null $defaultTimezone
283+
*
284+
* @return string
285+
*/
286+
protected function getDisplayTimezoneFromSession(?string $defaultTimezone = null): string
287+
{
288+
return Session::getSession()->getNamespace('notifications')->get('schedule.display_timezone', $defaultTimezone);
289+
}
290+
291+
/**
292+
* Write the display timezone to the session
293+
*
294+
* @param string $displayTimezone
295+
*
296+
* @return void
297+
*/
298+
protected function writeDisplayTimezoneToSession(string $displayTimezone): void
299+
{
300+
Session::getSession()->getNamespace('notifications')->set('schedule.display_timezone', $displayTimezone);
301+
}
207302
}

application/controllers/SchedulesController.php

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,12 @@
44

55
namespace Icinga\Module\Notifications\Controllers;
66

7-
use DateTime;
87
use Icinga\Module\Notifications\Common\Database;
98
use Icinga\Module\Notifications\Common\Links;
109
use Icinga\Module\Notifications\Model\Schedule;
1110
use Icinga\Module\Notifications\View\ScheduleRenderer;
1211
use Icinga\Module\Notifications\Web\Control\SearchBar\ObjectSuggestions;
1312
use Icinga\Module\Notifications\Widget\ItemList\ObjectList;
14-
use Icinga\Module\Notifications\Widget\TimeGrid\DaysHeader;
15-
use ipl\Html\Attributes;
16-
use ipl\Html\HtmlElement;
1713
use ipl\Stdlib\Filter;
1814
use ipl\Web\Compat\CompatController;
1915
use ipl\Web\Compat\SearchControls;
@@ -78,12 +74,6 @@ public function indexAction(): void
7874
))->openInModal()
7975
);
8076

81-
$this->addContent(new HtmlElement(
82-
'div',
83-
Attributes::create(['class' => 'schedules-header']),
84-
new DaysHeader((new DateTime())->setTime(0, 0), 7)
85-
));
86-
8777
$this->addContent(new ObjectList($schedules, new ScheduleRenderer()));
8878

8979
if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/* Icinga Notifications Web | (c) 2025 Icinga GmbH | GPLv2 */
4+
5+
namespace Icinga\Module\Notifications\Controllers;
6+
7+
use DateTime;
8+
use DateTimeZone;
9+
use IntlTimeZone;
10+
use ipl\Web\Compat\CompatController;
11+
use ipl\Web\FormElement\SearchSuggestions;
12+
use Throwable;
13+
14+
class SuggestController extends CompatController
15+
{
16+
public function timezoneAction(): void
17+
{
18+
$suggestions = new SearchSuggestions((function () use (&$suggestions) {
19+
foreach (IntlTimeZone::createEnumeration() as $tz) {
20+
try {
21+
if (
22+
(new DateTime('now', new DateTimeZone($tz)))->getTimezone()->getLocation()
23+
&& $suggestions->matchSearch($tz)
24+
) {
25+
yield ['search' => $tz];
26+
}
27+
} catch (Throwable) {
28+
continue;
29+
}
30+
}
31+
})());
32+
33+
$this->getDocument()->addHtml($suggestions->forRequest($this->getServerRequest()));
34+
}
35+
}

application/forms/RotationConfigForm.php

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use DateInterval;
88
use DateTime;
9+
use DateTimeZone;
910
use Generator;
1011
use Icinga\Exception\ConfigurationError;
1112
use Icinga\Exception\Http\HttpNotFoundException;
@@ -80,6 +81,12 @@ class RotationConfigForm extends CompatForm
8081
/** @var int The rotation id */
8182
protected $rotationId;
8283

84+
/** @var string The timezone to display the timeline in */
85+
protected $displayTimezone;
86+
87+
/** @var string The timezone the schedule is created in */
88+
protected $scheduleTimezone;
89+
8390
/**
8491
* Set the label for the submit button
8592
*
@@ -187,11 +194,15 @@ public function hasBeenWiped(): bool
187194
*
188195
* @param int $scheduleId
189196
* @param Connection $db
197+
* @param string $displayTimezone
198+
* @param string $scheduleTimezone
190199
*/
191-
public function __construct(int $scheduleId, Connection $db)
200+
public function __construct(int $scheduleId, Connection $db, string $displayTimezone, string $scheduleTimezone)
192201
{
193202
$this->db = $db;
194203
$this->scheduleId = $scheduleId;
204+
$this->displayTimezone = $displayTimezone;
205+
$this->scheduleTimezone = $scheduleTimezone;
195206

196207
$this->applyDefaultElementDecorators();
197208
}
@@ -227,7 +238,11 @@ public function loadRotation(int $rotationId): self
227238
throw new LogicException('Invalid mode');
228239
}
229240

230-
$handoff = DateTime::createFromFormat('Y-m-d H:i', $rotation->first_handoff . ' ' . $time);
241+
$handoff = DateTime::createFromFormat(
242+
'Y-m-d H:i',
243+
$rotation->first_handoff . ' ' . $time,
244+
new DateTimeZone($this->scheduleTimezone)
245+
);
231246
if ($handoff === false) {
232247
throw new ConfigurationError('Invalid date format');
233248
}
@@ -258,7 +273,9 @@ public function loadRotation(int $rotationId): self
258273
->orderBy('until_time', SORT_DESC)
259274
->first();
260275
if ($previousShift !== null) {
261-
$this->previousShift = $previousShift->until_time;
276+
$this->previousShift = $previousShift->until_time->setTimezone(
277+
new DateTimeZone($this->scheduleTimezone)
278+
);
262279
}
263280

264281
/** @var ?Rotation $newerRotation */
@@ -452,6 +469,9 @@ public function editRotation(int $rotationId): void
452469
->filter(Filter::equal('timeperiod.owned_by_rotation_id', $rotationId));
453470

454471
foreach ($timeperiodEntries as $timeperiodEntry) {
472+
$timeperiodEntry->start_time->setTimezone(new DateTimeZone($this->scheduleTimezone));
473+
$timeperiodEntry->end_time->setTimezone(new DateTimeZone($this->scheduleTimezone));
474+
455475
/** @var TimeperiodEntry $timeperiodEntry */
456476
$rrule = $timeperiodEntry->toRecurrenceRule();
457477
$shiftDuration = $timeperiodEntry->start_time->diff($timeperiodEntry->end_time);
@@ -819,9 +839,10 @@ protected function assemblePartialDayOptions(FieldsetElement $options): DateTime
819839
->replaceDecorator('Description', DescriptionDecorator::class, ['class' => 'description']);
820840

821841
$selectedFromTime = $from->getValue();
842+
$nextDayTimeOptions = [];
822843
foreach ($timeOptions as $key => $value) {
823-
unset($timeOptions[$key]); // unset to re-add it at the end of array
824-
$timeOptions[$key] = sprintf('%s (%s)', $value, $this->translate('Next Day'));
844+
unset($timeOptions[$key]);
845+
$nextDayTimeOptions[$key] = $value;
825846

826847
if ($selectedFromTime === $key) {
827848
break;
@@ -830,7 +851,9 @@ protected function assemblePartialDayOptions(FieldsetElement $options): DateTime
830851

831852
$to = $options->createElement('select', 'to', [
832853
'required' => true,
833-
'options' => $timeOptions
854+
'options' => empty($timeOptions)
855+
? [$this->translate('Next Day') => $nextDayTimeOptions]
856+
: [$this->translate('Same Day') => $timeOptions, $this->translate('Next Day') => $nextDayTimeOptions]
834857
]);
835858
$options->registerElement($to);
836859

@@ -1226,11 +1249,35 @@ function ($value, $validator) use ($earliestHandoff, $firstHandoff, $latestHando
12261249
(new \IntlDateFormatter(
12271250
\Locale::getDefault(),
12281251
\IntlDateFormatter::MEDIUM,
1229-
\IntlDateFormatter::SHORT
1252+
\IntlDateFormatter::SHORT,
1253+
$this->scheduleTimezone
12301254
))->format($actualFirstHandoff)
12311255
);
12321256
}
1233-
})
1257+
}),
1258+
new HtmlElement('br'),
1259+
$this->displayTimezone !== $this->scheduleTimezone ? DeferredText::create(function () {
1260+
$ruleGenerator = $this->yieldRecurrenceRules(1);
1261+
if (! $ruleGenerator->valid()) {
1262+
return '';
1263+
}
1264+
1265+
$actualFirstHandoff = $ruleGenerator->current()[0]->getStartDate();
1266+
if ($actualFirstHandoff < new DateTime()) {
1267+
return '';
1268+
} else {
1269+
return sprintf(
1270+
$this->translate('In your chosen display timezone (%s) this is the %s'),
1271+
$this->displayTimezone,
1272+
(new \IntlDateFormatter(
1273+
\Locale::getDefault(),
1274+
\IntlDateFormatter::MEDIUM,
1275+
\IntlDateFormatter::SHORT,
1276+
$this->displayTimezone
1277+
))->format($actualFirstHandoff)
1278+
);
1279+
}
1280+
}) : new HtmlDocument()
12341281
));
12351282
}
12361283

@@ -1293,12 +1340,13 @@ private function parseDateAndTime(?string $date = null, ?string $time = null): D
12931340
}
12941341

12951342
if (! $format) {
1296-
return (new DateTime())->setTime(0, 0);
1343+
return new DateTime('today', new DateTimeZone($this->scheduleTimezone));
12971344
}
12981345

1299-
$datetime = DateTime::createFromFormat($format, $expression);
1346+
$datetime = DateTime::createFromFormat($format, $expression, new DateTimeZone($this->scheduleTimezone));
1347+
13001348
if ($datetime === false) {
1301-
$datetime = (new DateTime())->setTime(0, 0);
1349+
$datetime = new DateTime('today', $this->scheduleTimezone);
13021350
} elseif ($time === null) {
13031351
$datetime->setTime(0, 0);
13041352
}
@@ -1316,11 +1364,12 @@ private function getTimeOptions(): array
13161364
$formatter = new \IntlDateFormatter(
13171365
\Locale::getDefault(),
13181366
\IntlDateFormatter::NONE,
1319-
\IntlDateFormatter::SHORT
1367+
\IntlDateFormatter::SHORT,
1368+
$this->scheduleTimezone
13201369
);
13211370

13221371
$options = [];
1323-
$dt = new DateTime();
1372+
$dt = new DateTime('now', new DateTimeZone($this->scheduleTimezone));
13241373
for ($hour = 0; $hour < 24; $hour++) {
13251374
for ($minute = 0; $minute < 60; $minute += 30) {
13261375
$dt->setTime($hour, $minute);

0 commit comments

Comments
 (0)