Skip to content

Commit 4ce30be

Browse files
Schedules: Add timescale and time indicator (#292)
resolves #283
2 parents 43e1cf2 + 0e30729 commit 4ce30be

File tree

3 files changed

+227
-2
lines changed

3 files changed

+227
-2
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
/* Icinga Notifications Web | (c) 2025 Icinga GmbH | GPLv2 */
4+
5+
namespace Icinga\Module\Notifications\Widget\TimeGrid;
6+
7+
use DateTime;
8+
use IntlDateFormatter;
9+
use ipl\Html\Attributes;
10+
use ipl\Html\BaseHtmlElement;
11+
use ipl\Html\HtmlElement;
12+
use ipl\Html\Text;
13+
use ipl\I18n\Translation;
14+
use ipl\Web\Style;
15+
use Locale;
16+
17+
/**
18+
* Creates a localized timescale for the TimeGrid
19+
*/
20+
class Timescale extends BaseHtmlElement
21+
{
22+
use Translation;
23+
24+
protected $tag = 'div';
25+
26+
protected $defaultAttributes = ['class' => 'timescale'];
27+
28+
/** @var int The number of days shown */
29+
protected $days;
30+
31+
/** @var Style */
32+
protected $style;
33+
34+
/**
35+
* Create a new Timescale
36+
*
37+
* @param int $days
38+
* @param Style $style
39+
*/
40+
public function __construct(int $days, Style $style)
41+
{
42+
$this->days = $days;
43+
$this->style = $style;
44+
}
45+
46+
public function assemble(): void
47+
{
48+
if ($this->days === 1) {
49+
$timestampPerDay = 12;
50+
} elseif ($this->days <= 7) {
51+
$timestampPerDay = 2;
52+
} else {
53+
$timestampPerDay = 1;
54+
}
55+
56+
$this->style->addFor($this, ['--timestampsPerDay' => $timestampPerDay * 2]); // *2 for .ticks
57+
58+
$dateFormatter = new IntlDateFormatter(
59+
Locale::getDefault(),
60+
IntlDateFormatter::NONE,
61+
IntlDateFormatter::SHORT
62+
);
63+
64+
$timeIntervals = 24 / $timestampPerDay;
65+
66+
$time = new DateTime();
67+
$dayTimestamps = [];
68+
for ($i = 0; $i < $timestampPerDay; $i++) {
69+
// am-pm is separated by non-breaking whitespace
70+
$parts = preg_split('/\s/u', $dateFormatter->format($time->setTime($i * $timeIntervals, 0)));
71+
72+
$stamp = [new HtmlElement('span', null, new Text($parts[0]))];
73+
if (isset($parts[1])) {
74+
$stamp[] = new HtmlElement('span', null, new Text($parts[1]));
75+
}
76+
77+
$dayTimestamps[] = new HtmlElement('span', new Attributes(['class' => 'timestamp']), ...$stamp);
78+
$dayTimestamps[] = new HtmlElement('span', new Attributes(['class' => 'ticks']));
79+
}
80+
81+
$allTimestamps = array_merge(...array_fill(0, $this->days, $dayTimestamps));
82+
// clone is required because $allTimestamps contains references of same object
83+
$allTimestamps[] = (clone $allTimestamps[0])->addAttributes(['class' => 'midnight']); // extra stamp of 12AM
84+
85+
$this->addHtml(...$allTimestamps);
86+
}
87+
}

library/Notifications/Widget/Timeline.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
use Icinga\Module\Notifications\Widget\TimeGrid\DynamicGrid;
1212
use Icinga\Module\Notifications\Widget\TimeGrid\EntryProvider;
1313
use Icinga\Module\Notifications\Widget\TimeGrid\GridStep;
14+
use Icinga\Module\Notifications\Widget\TimeGrid\Timescale;
15+
use Icinga\Module\Notifications\Widget\TimeGrid\Util;
1416
use Icinga\Module\Notifications\Widget\Timeline\Entry;
1517
use Icinga\Module\Notifications\Widget\Timeline\MinimalGrid;
1618
use Icinga\Module\Notifications\Widget\Timeline\Rotation;
19+
use IntlDateFormatter;
1720
use ipl\Html\Attributes;
1821
use ipl\Html\BaseHtmlElement;
1922
use ipl\Html\HtmlElement;
@@ -23,6 +26,7 @@
2326
use ipl\Web\Url;
2427
use ipl\Web\Widget\Icon;
2528
use ipl\Web\Widget\Link;
29+
use Locale;
2630
use SplObjectStorage;
2731
use Traversable;
2832

@@ -316,6 +320,42 @@ protected function assemble()
316320
Text::create($this->translate('Result'))
317321
)
318322
);
323+
324+
$dateFormatter = new IntlDateFormatter(
325+
Locale::getDefault(),
326+
IntlDateFormatter::NONE,
327+
IntlDateFormatter::SHORT
328+
);
329+
330+
$now = new DateTime();
331+
$currentTime = new HtmlElement(
332+
'div',
333+
new Attributes(['class' => 'time-hand']),
334+
new HtmlElement(
335+
'div',
336+
new Attributes(['class' => 'now', 'title' => $dateFormatter->format($now)]),
337+
Text::create($this->translate('now'))
338+
)
339+
);
340+
341+
$now = Util::roundToNearestThirtyMinute($now);
342+
343+
$this->getStyle()->addFor($currentTime, [
344+
'--timeStartColumn' =>
345+
$now->format('G') * 2 // 2 columns per hour
346+
+ ($now->format('i') >= 30 ? 1 : 0) // 1 column for the half hour
347+
+ 1 // CSS starts counting columns from 1, not zero
348+
]);
349+
350+
$clock = new HtmlElement(
351+
'div',
352+
new Attributes(['class' => 'clock']),
353+
new HtmlElement('div', new Attributes(['class' => 'current-day']), $currentTime)
354+
);
355+
356+
$this->getGrid()
357+
->addHtml(new Timescale($this->days, $this->getStyle()))
358+
->addHtml($clock);
319359
}
320360

321361
$this->addHtml(

public/css/timeline.less

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,22 @@
33
.timeline {
44
display: flex;
55
flex-direction: column;
6+
overflow: hidden;
67

78
.time-grid {
89
--sidebarWidth: 12em;
910
--stepRowHeight: 4em;
1011
--primaryRowHeight: 4em;
12+
--daysHeaderHeight: 3em;
1113
position: relative;
14+
margin-right: 1em; // make midnight timestamp visible
1215

1316
.time-grid-header {
1417
box-sizing: border-box;
1518
position: sticky;
16-
z-index: 1;
19+
z-index: 2; // overlap the .clock .time-hand
1720
top: 0;
21+
height: var(--daysHeaderHeight);
1822
}
1923

2024
.row-title {
@@ -47,6 +51,7 @@
4751
.overlay .entry {
4852
margin-top: 1em;
4953
margin-bottom: 1em;
54+
z-index: 2; // overlap the .clock .time-hand
5055

5156
.title {
5257
height: 100%;
@@ -65,9 +70,86 @@
6570
display: block;
6671
border-top: 1px solid black;
6772
position: absolute;
68-
bottom: var(--stepRowHeight);
6973
right: 0;
7074
left: 0;
75+
// -1 to exclude result row
76+
top: ~"calc((var(--stepRowHeight) * calc(var(--primaryRows) - 1)) + var(--daysHeaderHeight))";
77+
}
78+
79+
.timescale {
80+
display: grid;
81+
grid-template-columns: repeat(~"calc(var(--primaryColumns) * var(--timestampsPerDay))", minmax(0,1fr));
82+
border-left: 1px solid @gray-lighter; // this is required to maintain the grid layout
83+
grid-area: ~"4 / 2 / 4 / 3";
84+
85+
.ticks {
86+
position: relative;
87+
border-right: 1px solid @gray-lighter;
88+
border-left: 1px solid @gray-lighter;
89+
90+
&:after { // overlaps the unnecessary part of border-left
91+
content: '';
92+
position: absolute;
93+
top: 0.25em;
94+
left: -1px; // overlap the border-left
95+
right: 0;
96+
bottom: 0;
97+
background: @body-bg-color;
98+
}
99+
}
100+
101+
.timestamp {
102+
display: flex;
103+
flex-direction: column;
104+
align-items: center;
105+
margin-top: 0.5em;
106+
padding-top: 0.5em;
107+
font-size: .5em;
108+
position: relative;
109+
left: -50%;
110+
line-height: 1;
111+
112+
&.midnight {
113+
left: 50%;
114+
}
115+
116+
> span:last-child {
117+
opacity: 0.5;
118+
}
119+
}
120+
121+
span:nth-last-of-type(2), // last .ticks before .midnight
122+
.midnight {
123+
grid-area: ~"1 / -2 / 1 / -1";
124+
}
125+
}
126+
127+
.clock {
128+
display: grid;
129+
grid-template-columns: repeat(var(--primaryColumns), 1fr);
130+
grid-area: ~"3 / 2 / 4 / 3";
131+
border-top: 1px solid transparent; // left not required, otherwise the .time-hand is not aligned properly
132+
133+
.current-day {
134+
display: grid;
135+
grid-template-columns: repeat(var(--columnsPerStep), 1fr);
136+
grid-area: ~"1 / 1 / 2 / 2";
137+
138+
.time-hand {
139+
grid-area: ~"1 / var(--timeStartColumn) / 2 / calc(var(--timeStartColumn) + 1)";
140+
display: flex;
141+
align-items: flex-end;
142+
width: 1px;
143+
border-left: 1px solid red;
144+
z-index: 1;
145+
146+
.now {
147+
.rounded-corners();
148+
padding: 0 .25em;
149+
transform: translate(-50%, 50%);
150+
}
151+
}
152+
}
71153
}
72154
}
73155
}
@@ -134,6 +216,18 @@
134216
font-size: .75em;
135217
opacity: .8;
136218
}
219+
220+
.timescale .timestamp {
221+
color: @gray-semilight;
222+
background: @body-bg-color;
223+
}
224+
225+
.clock .now {
226+
background-color: @gray-light;
227+
font-size: 0.75em;
228+
color: red;
229+
.user-select(none);
230+
}
137231
}
138232

139233
.timeline.minimal-layout .empty-notice {
@@ -143,3 +237,7 @@
143237
.days-header .column-title .date {
144238
font-size: .75em;
145239
}
240+
241+
#layout.twocols .schedule .timescale:has(:nth-child(n+62)) { // month view (--timestampsPerDay * --primaryColumns = 62)
242+
display: none;
243+
}

0 commit comments

Comments
 (0)