mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-07-12 12:37:57 +00:00
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:
parent
74f6ddac6b
commit
de6a74832b
5 changed files with 225 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue