-
Notifications
You must be signed in to change notification settings - Fork 1
Decouple time widgets #350
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 27 commits
9da1580
5a2b6dd
97e7867
ab8aa8e
c620465
31cdf5e
29bdf2a
4207ad3
a1019c7
0072131
2f3d7b5
8ac3881
db2b8f5
6d17781
c8fd09a
9ef3790
b5cba9b
216f9d2
ff5030d
cd29ec9
4ec6e58
7d6db77
434f792
647c17e
89c3d0e
ceb8798
8d465de
71212bc
e5b266d
e2f8794
a065145
18ef512
7f1ab16
51b7a30
9b93fd5
7144566
000ed5e
a2085ad
639d72e
2e23df8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| }); |
| 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; | ||
| }); |
| 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm afraid, but I just noticed that requiring 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 |
||
| { | ||
| $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 | ||
|
||
| * | ||
| * @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())); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
||
| } | ||
| } | ||
There was a problem hiding this comment.
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?