fix: honor format="duration" on <relative-time> (#12415)

The admin system status template wraps the server start time in
<relative-time format="duration"> intending to display elapsed time as
a bare duration ("two months"), but the custom element ignored the
format attribute and always rendered "two months ago". Implement the
duration mode using Intl.NumberFormat with style: "unit" so the same
calendar-aware diffing produces a localized, suffix-free string.

Resolves https://codeberg.org/forgejo/forgejo/issues/12078

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12415
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Burak Emir 2026-07-10 18:43:12 +02:00 committed by Gusted
commit de6a74832b
5 changed files with 225 additions and 4 deletions

View file

@ -48,6 +48,34 @@
"one": "%d year ago",
"other": "%d years ago"
},
"relativetime.duration.secs": {
"one": "%d second",
"other": "%d seconds"
},
"relativetime.duration.mins": {
"one": "%d minute",
"other": "%d minutes"
},
"relativetime.duration.hours": {
"one": "%d hour",
"other": "%d hours"
},
"relativetime.duration.days": {
"one": "%d day",
"other": "%d days"
},
"relativetime.duration.weeks": {
"one": "%d week",
"other": "%d weeks"
},
"relativetime.duration.months": {
"one": "%d month",
"other": "%d months"
},
"relativetime.duration.years": {
"one": "%d year",
"other": "%d years"
},
"relativetime.1day": "yesterday",
"relativetime.1week": "last week",
"relativetime.1month": "last month",

View file

@ -205,6 +205,7 @@ func Contexter() func(next http.Handler) http.Handler {
ctx.PageData["PLURALSTRINGS_FALLBACK"] = map[string][]string{}
ctx.AddPluralStringsToPageData([]string{"relativetime.mins", "relativetime.hours", "relativetime.days", "relativetime.weeks", "relativetime.months", "relativetime.years"})
ctx.AddPluralStringsToPageData([]string{"relativetime.duration.secs", "relativetime.duration.mins", "relativetime.duration.hours", "relativetime.duration.days", "relativetime.duration.weeks", "relativetime.duration.months", "relativetime.duration.years"})
ctx.PageData["DATETIMESTRINGS"] = map[string]string{
"FUTURE": ctx.Locale.TrString("relativetime.future"),

View file

@ -19,7 +19,18 @@ test('Relative time after htmx swap', async ({page}, workerInfo) => {
await page.goto('/admin');
const relativeTime = page.locator('.admin-dl-horizontal > dd:nth-child(2) > relative-time');
await expect(relativeTime).toContainText('ago');
// The admin dashboard uses <relative-time format="duration"> for server
// uptime, which renders as a duration like "5 days, 3 hours" (no "ago").
// Check that the component produced a formatted duration rather than the
// raw datetime fallback.
const durationPattern = /\d+ (year|month|week|day|hour|minute|second)/;
await expect(relativeTime).toContainText(durationPattern);
// The <relative-time> custom element renders its formatted text into an open
// shadow root. Read that text directly: locator.textContent() does not
// reflect the shadow-DOM content, and an htmx morph re-adds the raw-datetime
// light-DOM fallback which would pollute toHaveText/toContainText.
const textBefore = await relativeTime.evaluate((el) => el.shadowRoot?.textContent ?? '');
const body = page.locator('body');
await body.evaluate(
@ -31,5 +42,10 @@ test('Relative time after htmx swap', async ({page}, workerInfo) => {
),
);
await expect(relativeTime).toContainText('ago');
// The system-status panel refreshes itself via htmx every 5 seconds. A
// previous regression (0e8d752d86) reset the rendered relative-time text on
// swap; assert the formatted text survives the swap unchanged.
await expect.poll(
() => relativeTime.evaluate((el) => el.shadowRoot?.textContent ?? ''),
).toBe(textBefore);
});

View file

@ -19,6 +19,29 @@ const ABSOLUTE_DATETIME_FORMAT = new Intl.DateTimeFormat(navigator.language, {
});
const FALLBACK_DATETIME_FORMAT = new Intl.RelativeTimeFormat(navigator.language, {style: 'long'});
// Fallback formatter for duration units, used only when the corresponding
// `relativetime.duration.*` string is untranslated. Localizes via the browser
// rather than Forgejo's translations, so it follows navigator.language.
const DURATION_FORMATTERS = {};
function GetDurationFormatter(unit) {
if (!DURATION_FORMATTERS[unit]) {
DURATION_FORMATTERS[unit] = new Intl.NumberFormat(navigator.language, {
style: 'unit',
unit,
unitDisplay: 'long',
});
}
return DURATION_FORMATTERS[unit];
}
// Joins the (up to two) duration units with a locale-correct separator, e.g.
// "1 year, 5 days". The unit words themselves come from Forgejo translations;
// this only supplies the punctuation/conjunction between them.
const DURATION_LIST_FORMAT = new Intl.ListFormat(navigator.language, {
style: 'long',
type: 'unit',
});
/**
* A list of plural rules for all languages.
* `plural_rules.go` defines the index for each of the 14 known plural rules.
@ -92,6 +115,64 @@ function GetPluralizedStringOrFallback(key, n, unit) {
return FALLBACK_DATETIME_FORMAT.format(-n, unit);
}
// Maps a dayjs/Intl time unit to its `relativetime.duration.*` translation key.
const DURATION_KEYS = {
year: 'relativetime.duration.years',
month: 'relativetime.duration.months',
week: 'relativetime.duration.weeks',
day: 'relativetime.duration.days',
hour: 'relativetime.duration.hours',
minute: 'relativetime.duration.mins',
second: 'relativetime.duration.secs',
};
/**
* Format amount `n` of the given time unit as a localized, suffix-free duration
* word (e.g. "5 days") using Forgejo's translations, falling back to the
* browser's Intl formatting when the string is untranslated.
*/
function FormatDurationUnit(n, unit) {
const translation = pageData.PLURALSTRINGS_LANG[DURATION_KEYS[unit]]?.[PLURAL_RULES[pageData.PLURAL_RULE_LANG](n)];
if (translation) return translation.replace('%d', n);
return GetDurationFormatter(unit).format(n);
}
// Ordered coarsest-to-finest. For each entry, when the primary unit fits, we
// also try to express the leftover in the `remainder` unit ("1 year, 5 days").
// `next` is the recommended refresh interval, paced by the displayed remainder
// so the visible text doesn't grow stale.
const DURATION_UNITS = [
{primary: 'year', remainder: 'day', next: ONE_DAY},
{primary: 'month', remainder: 'day', next: ONE_DAY},
{primary: 'week', remainder: 'day', next: ONE_DAY},
{primary: 'day', remainder: 'hour', next: ONE_HOUR},
{primary: 'hour', remainder: 'minute', next: ONE_MINUTE},
{primary: 'minute', remainder: 'second', next: HALF_MINUTE},
];
/**
* Format the difference between two dayjs UTC instants as a localized,
* absolute-value duration string (e.g. "2 months, 5 days", "3 hours, 5
* minutes") with no "ago"/"in" suffix. Shows up to two units, joined with
* locale-correct separators via Intl.ListFormat; omits the remainder when
* it rounds down to zero. Returns [text, recommendedUpdateIntervalMs].
*/
function FormatAsDuration(nowJS, thenJS) {
if (nowJS.isBefore(thenJS)) [nowJS, thenJS] = [thenJS, nowJS];
for (const {primary, remainder, next} of DURATION_UNITS) {
const n = Math.floor(nowJS.diff(thenJS, primary));
if (n < 1) continue;
const parts = [FormatDurationUnit(n, primary)];
const r = Math.floor(nowJS.diff(thenJS.add(n, primary), remainder));
if (r >= 1) parts.push(FormatDurationUnit(r, remainder));
return [DURATION_LIST_FORMAT.format(parts), next];
}
const seconds = Math.max(Math.floor(nowJS.diff(thenJS, 'second')), 0);
return [FormatDurationUnit(seconds, 'second'), HALF_MINUTE];
}
/**
* Update the displayed text of the given relative-time DOM element with its
* human-readable, localized relative time string.
@ -111,6 +192,12 @@ export function DoUpdateRelativeTime(object, now) {
object.setAttribute('data-tooltip-content', ABSOLUTE_DATETIME_FORMAT.format(thenJS.toDate()));
if (object.getAttribute('format') === 'duration') {
const [text, next] = FormatAsDuration(nowJS, thenJS);
object.textContent = text;
return next;
}
if (nowJS.isBefore(thenJS)) {
// Datetime is in the future.
object.textContent = pageData.DATETIMESTRINGS.FUTURE;
@ -188,7 +275,7 @@ export function DoUpdateRelativeTime(object, now) {
}
window.customElements.define('relative-time', class extends HTMLElement {
static observedAttributes = ['datetime'];
static observedAttributes = ['datetime', 'format'];
alive = false;
contentSpan = null;
@ -201,6 +288,10 @@ window.customElements.define('relative-time', class extends HTMLElement {
this.contentSpan = document.createElement('span');
this.contentSpan.setAttribute('part', 'relative-time');
this.shadowRoot.append(this.contentSpan);
// Remove light DOM children (the fallback datetime text from the
// template) so they don't leak into text extraction by Playwright
// or other tools that read both light and shadow DOM content.
this.replaceChildren();
}
const next = DoUpdateRelativeTime(this);
@ -217,7 +308,7 @@ window.customElements.define('relative-time', class extends HTMLElement {
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'datetime' && oldValue !== newValue) this.update(false);
if ((name === 'datetime' || name === 'format') && oldValue !== newValue) this.update(false);
}
set textContent(value) {

View file

@ -207,3 +207,88 @@ test('CalculateRelativeTimes', () => {
expect(DoUpdateRelativeTime(mock, now)).toEqual(ONE_HOUR);
expect(mock.textContent).toEqual('21 hours ago');
});
test('CalculateDurationFormat', () => {
window.config.pageData.PLURAL_RULE_LANG = 0;
// The suffix-free duration strings, mirroring options/locale_next translations.
const durationStrings = {
second: ['%d second', '%d seconds'],
minute: ['%d minute', '%d minutes'],
hour: ['%d hour', '%d hours'],
day: ['%d day', '%d days'],
week: ['%d week', '%d weeks'],
month: ['%d month', '%d months'],
year: ['%d year', '%d years'],
};
window.config.pageData.PLURALSTRINGS_LANG = {
'relativetime.duration.secs': durationStrings.second,
'relativetime.duration.mins': durationStrings.minute,
'relativetime.duration.hours': durationStrings.hour,
'relativetime.duration.days': durationStrings.day,
'relativetime.duration.weeks': durationStrings.week,
'relativetime.duration.months': durationStrings.month,
'relativetime.duration.years': durationStrings.year,
};
// Build expected strings from the seeded translations, joined with the same
// locale list formatter the component uses (plural rule 0: one iff n === 1).
const listFmt = new Intl.ListFormat(navigator.language, {style: 'long', type: 'unit'});
const unitStr = (n, unit) => durationStrings[unit][n === 1 ? 0 : 1].replace('%d', n);
const fmt = (...pairs) => listFmt.format(pairs.map(([n, unit]) => unitStr(n, unit)));
const mock = document.createElement('relative-time');
document.body.append(mock);
mock.setAttribute('format', 'duration');
const now = Date.parse('2024-10-27T04:05:30+01:00');
// Server uptime of just over two months: must render as "2 months, 2 days",
// not "2 months ago".
mock.setAttribute('datetime', '2024-08-25T01:00:00+02:00');
expect(DoUpdateRelativeTime(mock, now)).toEqual(ONE_DAY);
expect(mock.textContent).toEqual(fmt([2, 'month'], [2, 'day']));
// One year, some days remainder.
mock.setAttribute('datetime', '2023-08-25T01:00:00+02:00');
expect(DoUpdateRelativeTime(mock, now)).toEqual(ONE_DAY);
expect(mock.textContent).toEqual(fmt([1, 'year'], [63, 'day']));
// Exactly one month, no remainder days — single unit.
mock.setAttribute('datetime', '2024-09-27T04:05:30+01:00');
expect(DoUpdateRelativeTime(mock, now)).toEqual(ONE_DAY);
expect(mock.textContent).toEqual(fmt([1, 'month']));
// Days with hours remainder.
mock.setAttribute('datetime', '2024-10-22T03:05:30+01:00');
expect(DoUpdateRelativeTime(mock, now)).toEqual(ONE_HOUR);
expect(mock.textContent).toEqual(fmt([5, 'day'], [1, 'hour']));
// Hours with minutes remainder — refresh once a minute.
mock.setAttribute('datetime', '2024-10-27T01:00:30+01:00');
expect(DoUpdateRelativeTime(mock, now)).toEqual(ONE_MINUTE);
expect(mock.textContent).toEqual(fmt([3, 'hour'], [5, 'minute']));
// Minutes with seconds remainder.
mock.setAttribute('datetime', '2024-10-27T04:00:00+01:00');
expect(DoUpdateRelativeTime(mock, now)).toEqual(HALF_MINUTE);
expect(mock.textContent).toEqual(fmt([5, 'minute'], [30, 'second']));
// Sub-minute durations should still be expressed as a duration, not "now".
mock.setAttribute('datetime', '2024-10-27T04:05:20+01:00');
expect(DoUpdateRelativeTime(mock, now)).toEqual(HALF_MINUTE);
expect(mock.textContent).toEqual(fmt([10, 'second']));
// Future datetime should still produce a positive (absolute) duration.
mock.setAttribute('datetime', '2024-10-27T04:08:30+01:00');
expect(DoUpdateRelativeTime(mock, now)).toEqual(HALF_MINUTE);
expect(mock.textContent).toEqual(fmt([3, 'minute']));
// Untranslated unit falls back to the browser's Intl formatting (no "ago").
delete window.config.pageData.PLURALSTRINGS_LANG['relativetime.duration.months'];
const intlFallback = new Intl.NumberFormat(navigator.language, {
style: 'unit', unit: 'month', unitDisplay: 'long',
}).format(2);
mock.setAttribute('datetime', '2024-08-27T04:05:30+01:00');
expect(DoUpdateRelativeTime(mock, now)).toEqual(ONE_DAY);
expect(mock.textContent).toEqual(intlFallback);
});