Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
114 changes: 114 additions & 0 deletions asset/js/behavior/RelativeTimeBehavior.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/* 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);

this.on('submit', '[name=form_config_preferences]', this.onPreferencesSubmit, this);
this.on('rendered', '#main > .container, #modal-content', this.onRendered, this);
this.on('close-column', this.stop, this);
this.on('close-modal', this.stop, this);

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

/**
* Timer handle
*
* @type {null|*}
* @private
*/
this._timerHandle = null;
}

onPreferencesSubmit(event)
{
const timezoneElement = event.target.querySelector('[name=timezone]');
let timezone = timezoneElement.value;
if (timezone === 'autodetect') {
timezone = timezoneElement.querySelector('[value=autodetect]').innerHTML.match(/\((.*)\)/)[1];
}

icinga.config.timezone = timezone
}

/**
* Handle rendered event
*
* @param event
*/
onRendered(event) {
let _this = event.data.self;
const root = event.currentTarget || event.target;
const element = root && root.nodeType === 1 ? root : null; // 1 = Element
const hasRelativeTime =
element
&& (element.matches('time[data-relative-time]') || element.querySelector('time[data-relative-time]'));

if (!hasRelativeTime) {
return;
}

_this._relativeTime.update(root);

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

/**
* Stop the timer
*
* @param event
*/
stop(event) {
const _this = event?.data?.self || this;

if (_this._timerHandle == null) {
return;
}

const timer = _this.icinga.timer;

if (typeof timer.unregister === 'function') {
try {
timer.unregister(_this._timerHandle);
} catch (e) {
// ignore
}
} else if (typeof timer.remove === 'function') {
try {
timer.remove(_this._timerHandle);
} catch (e) {
// ignore
}
} else {
// Best effort fallback for older timer APIs
try {
timer.unregister(function() { _this._relativeTime.update(); }, _this);
} catch (e) {
// ignore
}
}

_this._timerHandle = null;
}
}

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

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

"use strict";

class RelativeTime {
/**
* @param icinga The Icinga application instance
*/
constructor(icinga) {
this.icinga = icinga;

this.formatter = new Intl.RelativeTimeFormat(
[icinga.config.locale, 'en'],
{style: 'narrow'}
);
}

/**
* Update relative time elements within the given root
*
* @param root The root element to search within
*/
update(root = document) {
const RELATIVE_TIME_THRESHOLD = 60 * 60;

const timezone = ((root) => {
const doc = root?.nodeType === 9 ? root : (root?.ownerDocument || document);

return icinga.config.timezone;
})(root);

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

const date = new Date(isoString + 'Z'); // Parse as UTC temporarily

const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZoneName: 'longOffset'
});

const parts = formatter.formatToParts(date);
const offsetPart = parts.find(part => part.type === 'timeZoneName');
const offsetString = offsetPart.value.replace('GMT', '');
// Create ISO 8601 string with timezone offset
const dateTimeString = `${isoString}${offsetString}`;

const givenTimeUTC = Date.parse(dateTimeString);

const now = Date.now();

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

root.querySelectorAll('time[data-relative-time="ago"], time[data-relative-time="since"]')
.forEach((element) => {
const mode = element.dataset.relativeTime;

let diffSeconds = getTimeDifferenceInSeconds(element, timezone);
if (diffSeconds < 0) {
diffSeconds = 0;
}

if (diffSeconds >= RELATIVE_TIME_THRESHOLD) {
return;
}

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

element.innerHTML = this.render(minute, second, mode);
});

root.querySelectorAll('time[data-relative-time="until"]')
.forEach((element) => {
let remainingSeconds = getTimeDifferenceInSeconds(element, timezone, true);

if (Math.abs(remainingSeconds) >= RELATIVE_TIME_THRESHOLD) {
return;
}

if (remainingSeconds === 0 && element.dataset.agoLabel) {
element.innerText = element.dataset.agoLabel;
element.dataset.relativeTime = 'ago';

return;
}

const absSeconds = remainingSeconds * (remainingSeconds < 0 ? -1 : 1);

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

element.innerHTML = this.render(minute, second, 'until');
});
}

/**
* Render the relative time string
*
* @param minute
* @param second
* @param mode
* @returns {string}
*/
render(minute, second, mode) {
const sign = mode === 'ago' || mode === 'since' ? -1 : 1;

let min = minute * sign;
let sec = second * sign;

const minutes = this.formatter.formatToParts(min, 'minute');
const seconds = this.formatter.formatToParts(sec, 'second');
let isPrefix = true;
let prefix = '', suffix = '';
for (let i = 0; i < seconds.length; i++) {
if (seconds[i].type === 'integer') {
if (i === 0) {
isPrefix = false;
}
continue;
}

if (seconds[i].value === minutes[i].value) {
if (isPrefix) {
prefix = seconds[i].value;
} else {
suffix = seconds[i].value;
}
break;
}

const a = String(seconds[i].value);
const b = String(minutes[i].value);
const maxLen = Math.min(a.length, b.length);

// helper: longest common prefix
const lcp = () => {
let common = '';
for (let k = 1; k <= maxLen; k++) {
const cand = a.slice(0, k);
if (b.startsWith(cand)) {
common = cand;
} else {
break;
}
}
return common;
};

// helper: longest common suffix
const lcs = () => {
let common = '';
for (let k = 1; k <= maxLen; k++) {
const cand = a.slice(-k);
if (b.endsWith(cand)) {
common = cand;
} else {
break;
}
}
return common;
};

if (isPrefix) {
const common = lcp();
if (common && common.trim().length) {
prefix = common;
}
} else {
const common = lcs();
if (common && common.trim().length) {
suffix = common;
}
}
}

return prefix + minute + 'm ' + second + 's ' + suffix;
}
}

return RelativeTime;
});
Loading
Loading