Skip to content

Decouple time widgets#350

Open
Jan-Schuppik wants to merge 55 commits intomainfrom
decouple-time-widgets
Open

Decouple time widgets#350
Jan-Schuppik wants to merge 55 commits intomainfrom
decouple-time-widgets

Conversation

@Jan-Schuppik
Copy link
Copy Markdown

@Jan-Schuppik Jan-Schuppik commented Jan 8, 2026

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:

Depends on:

Create a general Time Widget which is also the base for
TimeAgo, TimeSince & TimeUntil
@cla-bot cla-bot bot added the cla/signed label Jan 8, 2026
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.
Copy link
Copy Markdown
Member

@nilmerg nilmerg left a comment

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please tag this as @internal so we have the option to remove it.

*/
protected function format(): string
{
return $this->timeString;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please allow customizing this, by introducing a setter: setFormatter(IntlDateFormatter $formatter): static

*
* @param DateTime $time
*
* @return array [string<formattedTime>, int<type>, DateInterval]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
* @return array [string<formattedTime>, int<type>, DateInterval]
* @return array{0: string, 1: self::TIME|self::DATE|self::RELATIVE, 2: DateInterval}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Comment on lines +49 to +58
$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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment on lines +29 to +43
[, , $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));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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
*
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this should mention that it is called only during assembly of the widget.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What I've commented for TimeAgo also applies here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What I've commented for TimeAgo also applies here.

Comment on lines +6 to +18
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;
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

{
$now = new DateTime();
if ((int) $now->format('H') < 3) {
$this->markTestSkipped('Unreliable before 03:00 local time');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.
@Jan-Schuppik Jan-Schuppik force-pushed the decouple-time-widgets branch 2 times, most recently from 6d8dbd0 to c467617 Compare March 23, 2026 11:54
@Jan-Schuppik Jan-Schuppik force-pushed the decouple-time-widgets branch from c467617 to 65cf226 Compare March 23, 2026 12:13
- 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
Copy link
Copy Markdown
Contributor

@BastianLedererIcinga BastianLedererIcinga left a comment

Choose a reason for hiding this comment

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

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+ */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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, '') ?? '',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please refactor the tests as @nilmerg suggested. This entire test, testRenderHasAgoLabel and testRenderUsesTimeHtmlTag become superfluous once assertHtml is used.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants