Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
9da1580
Introduce independ time-widgets
Jan-Schuppik Dec 15, 2025
5a2b6dd
Add data-relative-time-attribute
Jan-Schuppik Jan 8, 2026
97e7867
Fix linting errors
Jan-Schuppik Jan 8, 2026
ab8aa8e
Add relative-time behavior (WIP)
Jan-Schuppik Jan 19, 2026
c620465
Fix missing timezone handling in relative-time behavior
Jan-Schuppik Jan 20, 2026
31cdf5e
cleanup time widgets
Jan-Schuppik Jan 21, 2026
29bdf2a
Create a relative-time behavior as expected
Jan-Schuppik Jan 21, 2026
4207ad3
Fix timezone handling - use available feature
Jan-Schuppik Jan 21, 2026
a1019c7
Cleanup: relative-time-behavior
Jan-Schuppik Jan 21, 2026
0072131
Split relative-time in core and behavior
Jan-Schuppik Jan 21, 2026
2f3d7b5
Remove notjQuery and simplify RelativeTime parameter
Jan-Schuppik Jan 22, 2026
8ac3881
Add timezone to Time-widget
Jan-Schuppik Jan 22, 2026
db2b8f5
Add tryout: timezone refreshing mechanism
Jan-Schuppik Jan 22, 2026
6d17781
Adjust: get timezone from icinga config in js
Jan-Schuppik Jan 22, 2026
c8fd09a
Cleanup: code style & variable names
Jan-Schuppik Jan 23, 2026
9ef3790
Apply suggestions: adjust phpdocs of Time.php
Jan-Schuppik Jan 27, 2026
b5cba9b
Add: auto-refresh for timezones in js
Jan-Schuppik Jan 30, 2026
216f9d2
Adjust: rendering of the timestrings
Jan-Schuppik Jan 30, 2026
ff5030d
Introduce: caching of timeformat
Jan-Schuppik Feb 5, 2026
cd29ec9
Adjust calculating the relative time
Jan-Schuppik Feb 5, 2026
4ec6e58
Add caching of timezone offset
Jan-Schuppik Feb 5, 2026
7d6db77
Add cache for offset and element time differences
Jan-Schuppik Feb 6, 2026
434f792
Remove element time difference cache (it slows down)
Jan-Schuppik Feb 6, 2026
647c17e
Fix: refresh cache on update & remove minus sign prefix
Jan-Schuppik Feb 11, 2026
89c3d0e
Remove timezone handling
Jan-Schuppik Feb 11, 2026
ceb8798
Apply suggestions: simplify the time widgets
Jan-Schuppik Feb 16, 2026
8d465de
Adjust: use cache instead of ago-label
Jan-Schuppik Feb 17, 2026
71212bc
Fix comments of time widget
Jan-Schuppik Feb 27, 2026
e5b266d
Add constructor to subclasses to avoid breaking change
Jan-Schuppik Feb 27, 2026
e2f8794
Fix usage of t() and N_()
Jan-Schuppik Feb 27, 2026
a065145
Introduce: unit-tests for time widgets
Jan-Schuppik Mar 2, 2026
18ef512
Fix: year-boundary tests and add missing edge-case coverage
Jan-Schuppik Mar 2, 2026
7f1ab16
Refactor: remove t() and N_() namespace workaround from tests
Jan-Schuppik Mar 2, 2026
51b7a30
Revert "Refactor: remove t() and N_() namespace workaround from tests"
Jan-Schuppik Mar 2, 2026
9b93fd5
Fix: linting error
Jan-Schuppik Mar 2, 2026
7144566
fix(Time): apply timezone after constructing DateTime from timestamp
Jan-Schuppik Mar 11, 2026
000ed5e
fix(RelativeTime): preserve sign through render pipeline
Jan-Schuppik Mar 11, 2026
a2085ad
fix(TimeUntil): add ago label to animated TimeUntil widgets
Jan-Schuppik Mar 11, 2026
639d72e
test: add tests to verify the ago label is set properly
Jan-Schuppik Mar 11, 2026
2e23df8
fix: remove unused DATETIME constant
Jan-Schuppik Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions asset/js/behavior/RelativeTimeBehavior.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* Icinga Web 2 | (c) 2025 Icinga GmbH | GPLv2+ */

define(["../widget/RelativeTime", "icinga/legacy-app/Icinga"], function (RelativeTime, Icinga) {

"use strict";

class RelativeTimeBehavior extends Icinga.EventListener {
constructor(icinga) {
super(icinga);

/**
* RelativeTime instance
*
* @type {RelativeTime}
* @private
*/
this._relativeTime = new RelativeTime(icinga.config.timezone);

this._relativeTime.update(document);

if (this._timerHandle == null) {
this._timerHandle = icinga.timer.register(
() => {this._relativeTime.update(document); },
this,
1000
);
}
}
}

Icinga.Behaviors = Icinga.Behaviors || {};

Icinga.Behaviors.RelativeTime = RelativeTimeBehavior;
});
146 changes: 146 additions & 0 deletions asset/js/widget/RelativeTime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
define([], function () {

"use strict";

const TIME_REGEX_FULL = /^(.*?)(\d+)([^\d\s]+)\s+(\d+)([^\d\s]+)(.*?)$/;
const TIME_REGEX_MIN_ONLY = /^(.*?)(\d+)([^\d\s]+)(.*?)$/;
// const TIME_REGEX_DIGITS = /(\d+)\s*[^\d\s]+\s+(\d+)/;
// const TIME_REGEX_DIGITS = /(\d{1,2})m (\d{1,2})s/.exec(content);
const TIME_REGEX_DIGITS = /(\d{1,2})(?!\d|[:;\-._,])\s*[^\d\s]+\s+(\d{1,2})/;

class RelativeTime {

constructor(timezone) {
this.timezone = timezone;
this._offsetCache = null;
this._templateCache = new Map();
}

update(root = document) {
const DYNAMIC_RELATIVE_TIME_THRESHOLD = 60 * 60;

this._offsetCache = null;
this._templateCache = new Map();

root.querySelectorAll('time[data-relative-time="ago"], time[data-relative-time="since"]')
.forEach((element) => {
const diffSeconds = this._getTimeDifferenceInSeconds(element);
if (diffSeconds == null || diffSeconds >= DYNAMIC_RELATIVE_TIME_THRESHOLD) {
return;
}

element.textContent = this.render(diffSeconds, element);
});

root.querySelectorAll('time[data-relative-time="until"]')
.forEach((element) => {
let remainingSeconds = this._getTimeDifferenceInSeconds(element, true);
if (remainingSeconds == null || Math.abs(remainingSeconds) >= DYNAMIC_RELATIVE_TIME_THRESHOLD) {
return;
}

if (remainingSeconds === 0) {
element.dataset.relativeTime = 'ago';
}

element.textContent = this.render(Math.abs(remainingSeconds), element);
});
}

_getTimeDifferenceInSeconds(element, future = false) {

const fromDateTimeWithTimezone = (element, future = false) => {
const timeString = element.dateTime || element.getAttribute('datetime');
const isoString = timeString.replace(' ', 'T');

const offset = this._getOffset();

const targetTimeUTC = Date.parse(`${isoString}${offset}`);
const now = Date.now();

return Math.floor((future ? targetTimeUTC - now : now - targetTimeUTC) / 1000);
};

const fromTextContent = (element, future = false) => {
const content = element.textContent;

const partialTime = TIME_REGEX_DIGITS.exec(content);

if (!partialTime) {
return null;
}

const minutes = parseInt(partialTime[1], 10);
const seconds = parseInt(partialTime[2], 10);

let secondsDiff = minutes * 60 + seconds;

return future ? --secondsDiff * -1 : ++secondsDiff;
};

// return fromTextContent(element, future);
return fromDateTimeWithTimezone(element, future);
}

/**
* Parse and cache prefix / units / suffix once
*/
_getTemplate(element) {
const type = element.dataset.relativeTime; // 'ago', 'since', 'until'

if (this._templateCache.has(type)) {
return this._templateCache.get(type);
}

const content = element.textContent || '';
let match = TIME_REGEX_FULL.exec(content) || TIME_REGEX_MIN_ONLY.exec(content);

const template = {
prefix: (match?.[1]).replace(/-+$/g, '') ?? '',
minuteUnit: match?.[3] ?? 'm',
secondUnit: match?.[5] ?? 's',
suffix: match?.[6] ?? ''
};

this._templateCache.set(type, template);

return template
}

_getOffset() {
if (!this._offsetCache) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: this.timezone,
timeZoneName: 'longOffset'
});
const parts = formatter.formatToParts(new Date());
this._offsetCache = parts.find(p => p.type === 'timeZoneName')
.value.replace('GMT', '');
}

return this._offsetCache;
}

/**
* Render relative time string using cached template
*/
render(diffInSeconds, element) {
const template = this._getTemplate(element);

const minute = Math.floor(diffInSeconds / 60);
const second = diffInSeconds % 60;

return (
template.prefix +
minute +
template.minuteUnit +
' ' +
second +
template.secondUnit +
template.suffix
);
}
}

return RelativeTime;
});
111 changes: 111 additions & 0 deletions src/Widget/Time.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

namespace ipl\Web\Widget;

use DateTime;
use ipl\Html\Attributes;
use ipl\Html\BaseHtmlElement;
use ipl\Html\Text;

class Time extends BaseHtmlElement
{
/** @var int Format relative */
public const RELATIVE = 0;

/** @var int Format time */
public const TIME = 1;

/** @var int Format date */
public const DATE = 2;

/** @var int Format date and time */
public const DATETIME = 4;

/** @var DateTime time of this widget */
protected DateTime $dateTime;

/** @var string DateTime string in ISO 8601 format */
protected string $timeString;

/** @var string Tag of element. */
protected $tag = 'time';

public function __construct(DateTime $time)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's still no phpdoc for the constructor… is there a reason why you neglect to add this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid, but I just noticed that requiring DateTime is a breaking change. At least as long as this is the constructor for the child classes as well.

To solve this, please override the constructor in the child classes and widen the allowed types so that it's possible to pass what was previously allowed, but cast it to DateTime before calling the parent constructor.

{
$this->dateTime = $time;
$this->timeString = $time->format('Y-m-d H:i:s');
$this->addAttributes(Attributes::create(['title' => $this->timeString]));
}

/**
* Compute difference between two dates
*
* Returns an array with the interval, the formatted string and the type of difference
* Type can be one of the constants RELATIVE, TIME, DATE, DATETIME
* Passing null as a parameter will use the current time
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, it fails

*
* @param DateTime $time
*
* @return array [string<formattedTime>, int<type>, DateInterval]
*/
public function diff(DateTime $time): array
{
$now = new DateTime();

$interval = $now->diff($time);

if ($interval->days > 2) {
$type = static::DATE;
$formatted = $time->format(date('Y') === date('Y', $time->getTimestamp()) ? 'M j' : 'Y-m');
} elseif ($interval->days > 0) {
$type = static::RELATIVE;
$formatted = $interval->format('%dd %hh');
} elseif ($interval->h > 0) {
if (date('d') === date('d', $time->getTimestamp())) {
$type = static::TIME;
$formatted = $time->format('H:i');
} else {
$type = static::DATE;
$formatted = $time->format('M j H:i');
}
} else {
$type = static::RELATIVE;
$formatted = $interval->format('%im %ss');
}

return [$formatted, $type, $interval];
}

/**
* Get formatted time
*
* @return string
*/
protected function format(): string
{
return $this->timeString;
}

/**
* Return a relative time widget
*
* @param DateTime $time
*
* @return static
*/
public static function relative(DateTime $time): static
{
return $time->getTimestamp() < time() ? new TimeAgo($time) : new TimeUntil($time);
}

/**
* Assemble logic of subclasses
* Override this method to customize the output
*
* @return void
*/
protected function assemble(): void
{
$this->addHtml(Text::create($this->format()));
}
}
46 changes: 27 additions & 19 deletions src/Widget/TimeAgo.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,40 @@

namespace ipl\Web\Widget;

use Icinga\Date\DateFormatter;
use ipl\Html\BaseHtmlElement;
use ipl\Html\Attributes;
use ipl\Html\Text;

class TimeAgo extends BaseHtmlElement
class TimeAgo extends Time
{
/** @var int */
protected $ago;

protected $tag = 'time';

protected $defaultAttributes = ['class' => 'time-ago'];

public function __construct($ago)
protected function assemble(): void
{
$this->ago = (int) $ago;
$this->addAttributes(
Attributes::create(
[
'datetime' => $this->timeString,
'data-relative-time' => 'ago'
]
)
);

$this->addHtml(Text::create($this->format()));
}

protected function assemble()
protected function format(): string
{
$dateTime = DateFormatter::formatDateTime($this->ago);

$this->addAttributes([
'datetime' => $dateTime,
'title' => $dateTime
]);

$this->add(DateFormatter::timeAgo($this->ago));
static $onMessage = ['on %s', 'An event happened on the given date or date and time'];
static $map = [
self::RELATIVE => ['%s ago', 'An event that happened the given time interval ago'],
self::TIME => ['at %s', 'An event happened at the given time'],
self::DATE => null,
self::DATETIME => null,
];

[$time, $type] = $this->diff($this->dateTime);
$format = $map[$type] ?? $onMessage;

return sprintf(t(N_($format[0]), N_($format[1])), $time);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like you didn't understand how t() and N_() work yet. Look at their implementation, look where they're used (especially N_) and read up what gettext is and how it works.

Then open a PR at https://github.com/Icinga/developer-guidelines-drafts/pulls to explain what you've learned so that others don't make the same mistakes.

Yes, I mean this serious!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a great idea, now i understand how they work!

}
}
Loading
Loading