-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add method to format relative time #921
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| /*! | ||
| * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: GPL-3.0-or-later | ||
| */ | ||
|
|
||
| import { getLanguage } from './locale.ts' | ||
|
|
||
| export interface FormatDateOptions { | ||
| /** | ||
| * If set then instead of showing seconds since the timestamp show the passed message. | ||
| * @default false | ||
| */ | ||
| ignoreSeconds?: string | false | ||
|
|
||
| /** | ||
| * The relative time formatting option to use | ||
| * @default 'long | ||
| */ | ||
| relativeTime?: 'long' | 'short' | 'narrow' | ||
|
|
||
| /** | ||
| * Language to use | ||
| * @default 'current language' | ||
| */ | ||
| language?: string | ||
| } | ||
|
|
||
| /** | ||
| * Format a given time as "relative time" also called "humanizing". | ||
| * | ||
| * @param timestamp Timestamp or Date object | ||
| * @param opts Options for the formatting | ||
| */ | ||
| export function formatRelativeTime( | ||
| timestamp: Date|number = Date.now(), | ||
| opts: FormatDateOptions = {}, | ||
| ): string { | ||
| const options: Required<FormatDateOptions> = { | ||
| ignoreSeconds: false, | ||
| language: getLanguage(), | ||
| relativeTime: 'long' as const, | ||
| ...opts, | ||
| } | ||
|
|
||
| /** ECMA Date object of the timestamp */ | ||
| const date = new Date(timestamp) | ||
|
|
||
| const formatter = new Intl.RelativeTimeFormat([options.language, getLanguage()], { numeric: 'auto', style: options.relativeTime }) | ||
| const diff = date.getTime() - Date.now() | ||
| const seconds = diff / 1000 | ||
|
|
||
| if (Math.abs(seconds) < 59.5) { | ||
| return options.ignoreSeconds | ||
| || formatter.format(Math.round(seconds), 'second') | ||
| } | ||
|
|
||
| const minutes = seconds / 60 | ||
| if (Math.abs(minutes) <= 59) { | ||
| return formatter.format(Math.round(minutes), 'minute') | ||
| } | ||
| const hours = minutes / 60 | ||
| if (Math.abs(hours) < 23.5) { | ||
| return formatter.format(Math.round(hours), 'hour') | ||
| } | ||
| const days = hours / 24 | ||
| if (Math.abs(days) < 6.5) { | ||
| return formatter.format(Math.round(days), 'day') | ||
| } | ||
| if (Math.abs(days) < 27.5) { | ||
| const weeks = days / 7 | ||
| return formatter.format(Math.round(weeks), 'week') | ||
| } | ||
|
|
||
| // For everything above we show year + month like "August 2025" or month + day if same year like "May 12" | ||
| // This is based on a Nextcloud design decision: https://github.com/nextcloud/server/issues/29807#issuecomment-2431895872 | ||
| const months = days / 30 | ||
| const format: Intl.DateTimeFormatOptions = Math.abs(months) < 11 | ||
| ? { month: options.relativeTime, day: 'numeric' } | ||
| : { year: options.relativeTime === 'narrow' ? '2-digit' : 'numeric', month: options.relativeTime } | ||
|
|
||
| const dateTimeFormatter = new Intl.DateTimeFormat([options.language, getLanguage()], format) | ||
| return dateTimeFormatter.format(date) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| /*! | ||
| * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: GPL-3.0-or-later | ||
| */ | ||
|
|
||
| import { beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest' | ||
| import { formatRelativeTime } from '../lib' | ||
|
|
||
| const setLanguage = (lang: string) => document.documentElement.setAttribute('lang', lang) | ||
|
|
||
| describe('time - formatRelativeTime', () => { | ||
|
|
||
| beforeAll(() => { | ||
| vi.useFakeTimers({ now: new Date('2025-01-01T00:00:00Z') }) | ||
| }) | ||
|
|
||
| beforeEach(() => { | ||
| setLanguage('en') | ||
| }) | ||
|
|
||
| test.each` | ||
| input | relativeTime | expected | ||
| ${'2024-12-31T23:59:30Z'} | ${'long'} | ${'30 seconds ago'} | ||
| ${'2024-12-31T23:59:30Z'} | ${'short'} | ${'30 sec. ago'} | ||
| ${'2024-12-31T23:59:30Z'} | ${'narrow'} | ${'30s ago'} | ||
| ${'2024-12-31T23:55:00Z'} | ${'long'} | ${'5 minutes ago'} | ||
| ${'2024-12-31T23:55:00Z'} | ${'short'} | ${'5 min. ago'} | ||
| ${'2024-12-31T23:55:00Z'} | ${'narrow'} | ${'5m ago'} | ||
| ${'2025-01-01T10:00:00Z'} | ${'long'} | ${'in 10 hours'} | ||
| ${'2025-01-01T10:00:00Z'} | ${'short'} | ${'in 10 hr.'} | ||
| ${'2025-01-01T10:00:00Z'} | ${'narrow'} | ${'in 10h'} | ||
| ${'2025-01-02T11:00:00Z'} | ${'long'} | ${'tomorrow'} | ||
| ${'2025-01-03T11:00:00Z'} | ${'long'} | ${'in 2 days'} | ||
| ${'2025-01-07T12:00:00Z'} | ${'long'} | ${'next week'} | ||
| ${'2025-01-14T10:00:00Z'} | ${'long'} | ${'in 2 weeks'} | ||
| ${'2025-03-01T10:00:00Z'} | ${'long'} | ${'March 1'} | ||
| ${'2025-03-14T10:00:00Z'} | ${'long'} | ${'March 14'} | ||
| ${'2025-03-14T10:00:00Z'} | ${'short'} | ${'Mar 14'} | ||
| ${'2025-03-14T10:00:00Z'} | ${'narrow'} | ${'M 14'} | ||
|
Comment on lines
+36
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure that would fit all cases and expected formats (e.g.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you have an example? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. en_US and en_GB maybe? I didn't test, just popped out in my mind https://meta.m.wikimedia.org/wiki/Date_formats_in_various_languages I guess English would be the only overlap
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mean for different formats.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or do you mean that we need more then a language here, not a new format?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Not locale, but language. Works fine for English with British English, as there is British English as a language in Nextcloud). So the question from Maksim is, are there other such cases, where there is a different locale, but there is no different language (in Nextcloud).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I still do not get the problem here? Relative time is formatted in language not locale as the format is mostly returning a "human readable" string instead of a date format (which would be the case for plain date format like "May 14 2025" vs "14 May 2025") There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, it doesn't sound like a big deal, as long as month is not written as a number
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Somewhere there is a typo 👀
Depends on In However, as Maksim mentioned above, there are languages with different formats, for example, English with This case works correctly, as we have
So the question was, are there other cases where in a single language different formats can be expected here, and not covered in Nextcloud as different languages (like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That is true but there is no way for us to fix this, because often people use e.g. English language but other locale to get metric units, decimal comma instead of dot or different date format. So for relative time its more language than locale. And for the special case of US vs GB English I think it is already fixed as Nextcloud will report |
||
| ${'2026-01-01T10:00:00Z'} | ${'long'} | ${'January 2026'} | ||
| ${'2024-01-01T10:00:00Z'} | ${'long'} | ${'January 2024'} | ||
| ${'2024-01-01T10:00:00Z'} | ${'short'} | ${'Jan 2024'} | ||
| ${'2024-01-01T10:00:00Z'} | ${'narrow'} | ${'J 24'} | ||
| `('format date time $input as $expected', ({ input, relativeTime, expected }) => { | ||
| const date = new Date(input) | ||
| expect(formatRelativeTime(date, { relativeTime })).toBe(expected) | ||
| }) | ||
|
|
||
| it('can ignore seconds', () => { | ||
| expect(formatRelativeTime(new Date('2024-12-31T23:59:30Z'), { ignoreSeconds: 'few seconds ago' })).toBe('few seconds ago') | ||
| expect(formatRelativeTime(new Date('2024-12-31T23:58:00Z'), { ignoreSeconds: 'few seconds ago' })).toBe('2 minutes ago') | ||
| }) | ||
|
|
||
| it('uses user language by default', () => { | ||
| setLanguage('de') | ||
| expect(formatRelativeTime(new Date('2024-12-31T23:58:00Z'))).toBe('vor 2 Minuten') | ||
| }) | ||
|
|
||
| it('can override the lange as parameter', () => { | ||
| setLanguage('de') | ||
| expect(formatRelativeTime(new Date('2024-12-31T23:58:00Z'), { language: 'en' })).toBe('2 minutes ago') | ||
| }) | ||
|
|
||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What
as constdoes here? Isn't string always a const as it is immutable?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type is
'long'rather thanstringThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But you have explicit
: Required<FormatDateOptions>type, which definesrelativeTimeas'long' | 'short' | 'narrow'already