Skip to content

Commit 95c4637

Browse files
committed
feat: add method to format relative time
* needed for nextcloud/server#29807 * replaces nextcloud-libraries/nextcloud-vue#6543 Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 18d5c2a commit 95c4637

3 files changed

Lines changed: 153 additions & 0 deletions

File tree

lib/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,9 @@ export type { Translations } from './registry'
2222
export * from './date'
2323
export * from './locale'
2424
export * from './translation'
25+
26+
export {
27+
type FormatDateOptions,
28+
29+
formatRelativeTime,
30+
} from './time.ts'

lib/time.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
6+
import { getLanguage } from './locale.ts'
7+
8+
export interface FormatDateOptions {
9+
/**
10+
* If set then instead of showing seconds since the timestamp show the passed message.
11+
* @default false
12+
*/
13+
ignoreSeconds?: string | false
14+
15+
/**
16+
* The relative time formatting option to use
17+
* @default 'long
18+
*/
19+
relativeTime?: 'long' | 'short' | 'narrow'
20+
21+
/**
22+
* Language to use
23+
* @default 'current language'
24+
*/
25+
language?: string
26+
}
27+
28+
/**
29+
* Format a given time as "relative time" also called "humanizing".
30+
*
31+
* @param timestamp Timestamp or Date object
32+
* @param opts Options for the formatting
33+
*/
34+
export function formatRelativeTime(
35+
timestamp: Date|number = Date.now(),
36+
opts: FormatDateOptions = {},
37+
): string {
38+
const options: Required<FormatDateOptions> = {
39+
ignoreSeconds: false,
40+
language: getLanguage(),
41+
relativeTime: 'long' as const,
42+
...opts,
43+
}
44+
45+
/** ECMA Date object of the timestamp */
46+
const date = new Date(timestamp)
47+
48+
const formatter = new Intl.RelativeTimeFormat([options.language, getLanguage()], { numeric: 'auto', style: options.relativeTime })
49+
const diff = date.getTime() - Date.now()
50+
const seconds = diff / 1000
51+
52+
if (Math.abs(seconds) < 59.5) {
53+
return options.ignoreSeconds
54+
|| formatter.format(Math.round(seconds), 'second')
55+
}
56+
57+
const minutes = seconds / 60
58+
if (Math.abs(minutes) <= 59) {
59+
return formatter.format(Math.round(minutes), 'minute')
60+
}
61+
const hours = minutes / 60
62+
if (Math.abs(hours) < 23.5) {
63+
return formatter.format(Math.round(hours), 'hour')
64+
}
65+
const days = hours / 24
66+
if (Math.abs(days) < 6.5) {
67+
return formatter.format(Math.round(days), 'day')
68+
}
69+
if (Math.abs(days) < 27.5) {
70+
const weeks = days / 7
71+
return formatter.format(Math.round(weeks), 'week')
72+
}
73+
74+
// For everything above we show year + month like "August 2025" or month + day if same year like "May 12"
75+
// This is based on a Nextcloud design decision: https://github.com/nextcloud/server/issues/29807#issuecomment-2431895872
76+
const months = days / 30
77+
const format: Intl.DateTimeFormatOptions = Math.abs(months) < 11
78+
? { month: options.relativeTime, day: 'numeric' }
79+
: { year: options.relativeTime === 'narrow' ? '2-digit' : 'numeric', month: options.relativeTime }
80+
81+
const dateTimeFormatter = new Intl.DateTimeFormat([options.language, getLanguage()], format)
82+
return dateTimeFormatter.format(date)
83+
}

tests/time.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
6+
import { beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest'
7+
import { formatRelativeTime } from '../lib'
8+
9+
const setLanguage = (lang: string) => document.documentElement.setAttribute('lang', lang)
10+
11+
describe('time - formatRelativeTime', () => {
12+
13+
beforeAll(() => {
14+
vi.useFakeTimers({ now: new Date('2025-01-01T00:00:00Z') })
15+
})
16+
17+
beforeEach(() => {
18+
setLanguage('en')
19+
})
20+
21+
test.each`
22+
input | relativeTime | expected
23+
${'2024-12-31T23:59:30Z'} | ${'long'} | ${'30 seconds ago'}
24+
${'2024-12-31T23:59:30Z'} | ${'short'} | ${'30 sec. ago'}
25+
${'2024-12-31T23:59:30Z'} | ${'narrow'} | ${'30s ago'}
26+
${'2024-12-31T23:55:00Z'} | ${'long'} | ${'5 minutes ago'}
27+
${'2024-12-31T23:55:00Z'} | ${'short'} | ${'5 min. ago'}
28+
${'2024-12-31T23:55:00Z'} | ${'narrow'} | ${'5m ago'}
29+
${'2025-01-01T10:00:00Z'} | ${'long'} | ${'in 10 hours'}
30+
${'2025-01-01T10:00:00Z'} | ${'short'} | ${'in 10 hr.'}
31+
${'2025-01-01T10:00:00Z'} | ${'narrow'} | ${'in 10h'}
32+
${'2025-01-02T11:00:00Z'} | ${'long'} | ${'tomorrow'}
33+
${'2025-01-03T11:00:00Z'} | ${'long'} | ${'in 2 days'}
34+
${'2025-01-07T12:00:00Z'} | ${'long'} | ${'next week'}
35+
${'2025-01-14T10:00:00Z'} | ${'long'} | ${'in 2 weeks'}
36+
${'2025-03-01T10:00:00Z'} | ${'long'} | ${'March 1'}
37+
${'2025-03-14T10:00:00Z'} | ${'long'} | ${'March 14'}
38+
${'2025-03-14T10:00:00Z'} | ${'short'} | ${'Mar 14'}
39+
${'2025-03-14T10:00:00Z'} | ${'narrow'} | ${'M 14'}
40+
${'2026-01-01T10:00:00Z'} | ${'long'} | ${'January 2026'}
41+
${'2024-01-01T10:00:00Z'} | ${'long'} | ${'January 2024'}
42+
${'2024-01-01T10:00:00Z'} | ${'short'} | ${'Jan 2024'}
43+
${'2024-01-01T10:00:00Z'} | ${'narrow'} | ${'J 24'}
44+
`('format date time $input as $expected', ({ input, relativeTime, expected }) => {
45+
const date = new Date(input)
46+
expect(formatRelativeTime(date, { relativeTime })).toBe(expected)
47+
})
48+
49+
it('can ignore seconds', () => {
50+
expect(formatRelativeTime(new Date('2024-12-31T23:59:30Z'), { ignoreSeconds: 'few seconds ago' })).toBe('few seconds ago')
51+
expect(formatRelativeTime(new Date('2024-12-31T23:58:00Z'), { ignoreSeconds: 'few seconds ago' })).toBe('2 minutes ago')
52+
})
53+
54+
it('uses user language by default', () => {
55+
setLanguage('de')
56+
expect(formatRelativeTime(new Date('2024-12-31T23:58:00Z'))).toBe('vor 2 Minuten')
57+
})
58+
59+
it('can override the lange as parameter', () => {
60+
setLanguage('de')
61+
expect(formatRelativeTime(new Date('2024-12-31T23:58:00Z'), { language: 'en' })).toBe('2 minutes ago')
62+
})
63+
64+
})

0 commit comments

Comments
 (0)