diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index a81d2b99f4..b43c4881f4 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -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", diff --git a/services/context/context.go b/services/context/context.go index 650b8e33e8..3099208b05 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -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"), diff --git a/tests/e2e/relative-time.test.e2e.ts b/tests/e2e/relative-time.test.e2e.ts index 19e151fa79..882e3d3af0 100644 --- a/tests/e2e/relative-time.test.e2e.ts +++ b/tests/e2e/relative-time.test.e2e.ts @@ -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 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 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); }); diff --git a/web_src/js/webcomponents/relative-time.js b/web_src/js/webcomponents/relative-time.js index 776955623a..55c485cb58 100644 --- a/web_src/js/webcomponents/relative-time.js +++ b/web_src/js/webcomponents/relative-time.js @@ -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) { diff --git a/web_src/js/webcomponents/relative-time.test.js b/web_src/js/webcomponents/relative-time.test.js index e12fa3476b..81fd6ced1a 100644 --- a/web_src/js/webcomponents/relative-time.test.js +++ b/web_src/js/webcomponents/relative-time.test.js @@ -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); +});