Conversation
Create a general Time Widget which is also the base for TimeAgo, TimeSince & TimeUntil
For this change to work the following PR is required: Icinga/icingaweb2#5238
This is not the right place for timezone handling. It is also unnecessary for daylight saving time, because IANA time zones take care of the shift.
This reverts commit 7f1ab16.
Passing a timezone to `new DateTime()` when the input is a Unix timestamp prefixed with `@` is silently ignored by PHP — the timezone is always set to UTC in that case. Fix by calling `setTimezone()` explicitly after construction.
Instead of stripping the sign with Math.abs() before rendering and handling negation inconsistently across different calls, pass the raw signed value through and derive the display sign in render()
The until -> ago transition relies on a cached TimeAgo format. If none is cached yet, the transition silently fails. Fix by pre-assigning the ago label to TimeUntil widgets when they are animated (< 1h), ensuring the format is available when the transition occurs.
No code path in Time::diff() ever returns the DATETIME type, making the constant and its references in the subclass format maps dead code.
The ago-label is required on TimeAgo widgets with a sub-hour difference so users can toggle between relative and absolute time. The shared format string has been extracted into getRelativeTimeFormat() to avoid duplication with TimeUntil, and the JS-side deletion of data-ago-label has been removed so the label persists for subsequent toggles.
Replace deferred N_() string marking with direct t() calls to ensure strings are translated with the correct context. Also removes TimeAgo::getRelativeTimeFormat(), which made the translation context harder to trace for translators.
nilmerg
left a comment
There was a problem hiding this comment.
I stopped reading the tests once I saw more than one skip due to when the test is performed. Sorry, but this just non-sense. Tests must always succeed.
In addition, they make too heavy usage of contains and regex assertions. They test a HTML widget, so should assert the HTML structure: $obj actual, $html expected. Other tests exactly work like this and there is even a predefined assertion as part of ipl\Tests\Html\TestCase.
So please refactor your tests accordingly.
| * | ||
| * @throws \Exception | ||
| */ | ||
| protected function castToDateTime(int|float|DateTime|null $value = null): DateTime |
There was a problem hiding this comment.
Please tag this as @internal so we have the option to remove it.
src/Widget/Time.php
Outdated
| */ | ||
| protected function format(): string | ||
| { | ||
| return $this->timeString; |
There was a problem hiding this comment.
Please allow customizing this, by introducing a setter: setFormatter(IntlDateFormatter $formatter): static
src/Widget/Time.php
Outdated
| * | ||
| * @param DateTime $time | ||
| * | ||
| * @return array [string<formattedTime>, int<type>, DateInterval] |
There was a problem hiding this comment.
| * @return array [string<formattedTime>, int<type>, DateInterval] | |
| * @return array{0: string, 1: self::TIME|self::DATE|self::RELATIVE, 2: DateInterval} |
There was a problem hiding this comment.
src/Widget/TimeAgo.php
Outdated
| $onMessage = t('on %s', 'An event happened on the given date or date and time'); | ||
| $map = [ | ||
| self::RELATIVE => t('%s ago', 'An event that happened the given time interval ago'), | ||
| self::TIME => t('at %s', 'An event happened at the given time'), | ||
| self::DATE => null, | ||
| ]; | ||
|
|
||
| $this->addAttributes([ | ||
| 'datetime' => $dateTime, | ||
| 'title' => $dateTime | ||
| ]); | ||
| [$time, $type] = $this->diff($this->dateTime); | ||
|
|
||
| $this->add(DateFormatter::timeAgo($this->ago)); | ||
| return sprintf($map[$type] ?? $onMessage, $time); |
There was a problem hiding this comment.
I suppose this can be simplified using a match expression. In case diff() returns other types this needs to change anyway and a failing match expression makes it clear.
src/Widget/TimeAgo.php
Outdated
| [, , $interval] = $this->diff($this->dateTime); | ||
|
|
||
| $attributes = [ | ||
| 'datetime' => $this->timeString, | ||
| 'data-relative-time' => 'ago' | ||
| ]; | ||
|
|
||
| if ($interval->days === 0 && $interval->h === 0) { | ||
| $attributes['data-ago-label'] = sprintf( | ||
| t('%s ago', 'An event that happened the given time interval ago'), | ||
| '0m 0s' | ||
| ); | ||
| } | ||
|
|
||
| $this->addAttributes(Attributes::create($attributes)); |
There was a problem hiding this comment.
Since the last step here doesn't differ from the base implementation but this needs to access the interval of the diff, did you consider performing all steps in format() and don't override assemble()? Right now it calls diff() twice after all.
|
|
||
| /** | ||
| * Get formatted time | ||
| * |
There was a problem hiding this comment.
I think this should mention that it is called only during assembly of the widget.
There was a problem hiding this comment.
What I've commented for TimeAgo also applies here.
There was a problem hiding this comment.
What I've commented for TimeAgo also applies here.
tests/Widget/TimeAgoTest.php
Outdated
| if (! function_exists('ipl\Web\Widget\t')) { | ||
| function t(string $message, ?string $context = null): string | ||
| { | ||
| return $message; | ||
| } | ||
| } | ||
|
|
||
| if (! function_exists('ipl\Web\Widget\N_')) { | ||
| function N_(string $message): string | ||
| { | ||
| return $message; | ||
| } | ||
| } |
There was a problem hiding this comment.
The reason you need this, is that you use non-testable global functions in your implementation. Use the Translation trait instead and your test cases just need to setup an NoopTranslator just like other tests do it already.
tests/Widget/TimeTest.php
Outdated
| { | ||
| $now = new DateTime(); | ||
| if ((int) $now->format('H') < 3) { | ||
| $this->markTestSkipped('Unreliable before 03:00 local time'); |
There was a problem hiding this comment.
Tests relying on the current local time… Fake the time for …'s sake. 🙏
Allow the Time widget to accept a custom IntlDateFormatter instance, enabling locale-aware and customizable time formatting. Falls back to the default formatted datetime string when no formatter is set. Also declare the ext-intl PHP extension as an explicit dependency in composer.json.
PHPStan checks that the values in specified keys have the correct types. source: https://phpstan.org/writing-php-code/phpdoc-types#array-shapes
Remove the separate assemble() override in TimeAgo, TimeSince and TimeUntil. Attribute setup and text formatting are now handled entirely within format(), eliminating the duplicate diff() call and reducing the number of methods subclasses need to override.
…ocks Replace \Exception with the imported Exception class across Time and its subclasses, and add the missing Exception import and @throws annotation to Time::relative().
Previously, Time::diff() and Time::relative() always used new DateTime()
as the reference point, making deterministic unit tests impossible.
Introduce an optional $compareTime parameter throughout the widget family
so callers can inject an explicit reference time:
- Time::diff() accepts ?DateTime $compareTime instead of computing "now"
internally; Time::relative() forwards it to TimeAgo/TimeUntil.
- TimeAgo, TimeSince, and TimeUntil store the injected time in the new
Time::$compareTime property.
- Year/day comparisons using date('Y')/date('d') are replaced by the new
isSameByFormat() helper, removing the last implicit "now" dependencies.
- data-relative-time attributes are moved to $defaultAttributes, removing
duplicate code from format() methods.
- Fix inverted prefix logic in TimeUntil: only prepend '-' when invert
is 0 and the interval is non-zero.
6d8dbd0 to
c467617
Compare
c467617 to
65cf226
Compare
- Move all test classes out of namespace blocks into proper ipl\Tests\Web\Widget namespace - Extend TestCase instead of PHPUnit\Framework\TestCase directly - Initialize StaticTranslator with NoopTranslator in setUp() to avoid translation side effects - Consolidate redundant constructor tests (int/float/DateTime variants) into fewer, clearer cases - Rename test methods to focus on observable behavior rather than input type (e.g. testConstructorAcceptsIntTimestamp → testConstructorAcceptsTimestamp) - Add $compareTime parameter usage to pin time in tests for deterministic results
65cf226 to
7add9f3
Compare
BastianLedererIcinga
left a comment
There was a problem hiding this comment.
I see my icingadb-web PR is still not entirely compatible with your changes, since I see no proper way to include a data-ago-label in Time widgets, which is required for the switch from absolute to relative. Let's discuss a solution and this time document the agreed approach in this PR.
| @@ -0,0 +1,34 @@ | |||
| /* Icinga Web 2 | (c) 2025 Icinga GmbH | GPLv2+ */ | |||
There was a problem hiding this comment.
This is no longer part of Icinga Web 2.
| let match = TIME_REGEX_FULL.exec(content) || TIME_REGEX_MIN_ONLY.exec(content); | ||
|
|
||
| const template = { | ||
| prefix: (match?.[1]).replace(/-+$/g, '') ?? '', |
There was a problem hiding this comment.
If match is null replace() is called on undefined, so you need a second check if you want to be safe.
| }; | ||
|
|
||
| return fromTextContent(element, future); | ||
| // return fromDateTimeWithTimezone(element, future); |
There was a problem hiding this comment.
If there are no plans to use this please remove it and the dead code referenced by it.
| $this->assertSame($html, $rendered2); | ||
| } | ||
|
|
||
| public function testRenderHasAttributes(): void |
There was a problem hiding this comment.
Please refactor the tests as @nilmerg suggested. This entire test, testRenderHasAgoLabel and testRenderUsesTimeHtmlTag become superfluous once assertHtml is used.
This PR removes the direct dependency of the time widgets on Icinga Web 2 (\Icinga\Date\DateFormatter::formatDateTime()) and introduces a native relative-time formatting behavior in IPL Web.
Resolve:
TimeAgo,TimeUntilandTimeSincefrom Icinga Web #341Depends on:
Icingatoipl/webbehaviors icingaweb2#5238(brings the ability to create js-behaviors outside of icingaweb2 )