Skip to content

Commit 8be467c

Browse files
committed
feat: add useFormatRelativeTime composable using @nextcloud/l10n
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 89e43e5 commit 8be467c

File tree

4 files changed

+251
-147
lines changed

4 files changed

+251
-147
lines changed

src/components/NcDateTime/NcDateTime.vue

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,13 @@ h4 {
9292
<template>
9393
<span class="nc-datetime"
9494
:data-timestamp="timestamp"
95-
:title="formattedFullTime"
95+
:title
9696
v-text="formattedTime" />
9797
</template>
9898

9999
<script setup lang="ts">
100-
import { toRef } from 'vue'
101-
import { useFormatDateTime } from '../../composables/useFormatDateTime.ts'
100+
import { computed, toRef } from 'vue'
101+
import { useFormatRelativeTime, useFormatTime } from '../../composables/useFormatDateTime.ts'
102102
103103
const props = withDefaults(defineProps<{
104104
/**
@@ -130,8 +130,15 @@ const props = withDefaults(defineProps<{
130130
relativeTime: 'long',
131131
})
132132
133-
const {
134-
formattedTime,
135-
formattedFullTime,
136-
} = useFormatDateTime(toRef(() => props.timestamp), props)
133+
const timeOptions = computed(() => ({ format: props.format }))
134+
const relativeTimeOptions = computed(() => ({
135+
ignoreSeconds: props.ignoreSeconds,
136+
relativeTime: props.relativeTime || 'long',
137+
update: props.relativeTime !== false,
138+
}))
139+
140+
const title = useFormatTime(toRef(() => props.timestamp), timeOptions)
141+
const relativeTime = useFormatRelativeTime(toRef(() => props.timestamp), relativeTimeOptions)
142+
143+
const formattedTime = computed(() => props.relativeTime ? relativeTime.value : title.value)
137144
</script>

src/composables/useFormatDateTime.ts

Lines changed: 139 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -3,125 +3,177 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import type { MaybeRef } from 'vue'
7-
import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
8-
import { computed, onUnmounted, ref, toValue, watchEffect } from 'vue'
6+
import type { FormatDateOptions } from '@nextcloud/l10n'
7+
import type { MaybeRefOrGetter, Ref } from 'vue'
8+
9+
import { formatRelativeTime, getCanonicalLocale } from '@nextcloud/l10n'
10+
import { computed, onUnmounted, readonly, ref, toValue, watchEffect } from 'vue'
911
import { t } from '../l10n.js'
1012

11-
const FEW_SECONDS_AGO = {
12-
long: t('a few seconds ago'),
13-
short: t('seconds ago'), // FOR TRANSLATORS: Shorter version of 'a few seconds ago'
14-
narrow: t('sec. ago'), // FOR TRANSLATORS: If possible in your language an even shorter version of 'a few seconds ago'
13+
interface FormatRelativeTimeOptions extends Partial<Omit<FormatDateOptions, 'ignoreSeconds'>> {
14+
ignoreSeconds?: boolean
15+
16+
/**
17+
* If set to false the relative time will not be updated anymore.
18+
* @default true - Meaning the relative time will be updated if needed
19+
*/
20+
update?: boolean
1521
}
1622

17-
interface FormatDateOptions {
23+
interface FormatTimeOptions {
24+
/**
25+
* Locale to use for formatting.
26+
* @default current locale
27+
*/
28+
locale?: string
29+
30+
/**
31+
* The format used for displaying.
32+
*
33+
* @default { timeStyle: 'medium', dateStyle: 'short' }
34+
*/
35+
format?: Intl.DateTimeFormatOptions
36+
}
37+
38+
/**
39+
* @deprecated
40+
*/
41+
interface LegacyFormatDateTimeOptions {
1842
/**
1943
* The format used for displaying, or if relative time is used the format used for the title
2044
*/
2145
format?: Intl.DateTimeFormatOptions
2246
/**
2347
* Ignore seconds when displaying the relative time and just show `a few seconds ago`
2448
*/
25-
ignoreSeconds?: boolean
49+
ignoreSeconds?: true
2650
/**
2751
* Wether to display the timestamp as time from now
2852
*/
2953
relativeTime?: false | 'long' | 'short' | 'narrow'
3054
}
3155

56+
const FEW_SECONDS_AGO = {
57+
long: t('a few seconds ago'),
58+
short: t('seconds ago'), // FOR TRANSLATORS: Shorter version of 'a few seconds ago'
59+
narrow: t('sec. ago'), // FOR TRANSLATORS: If possible in your language an even shorter version of 'a few seconds ago'
60+
}
61+
3262
/**
33-
* Composable for formatting time stamps using current users locale and language
63+
* Format a timestamp or date object as relative time.
3464
*
35-
* @param {import('vue').MaybeRef<Date | number>} timestamp Current timestamp
36-
* @param {object} opts Optional options
37-
* @param {Intl.DateTimeFormatOptions} opts.format The format used for displaying, or if relative time is used the format used for the title (optional)
38-
* @param {boolean} opts.ignoreSeconds Ignore seconds when displaying the relative time and just show `a few seconds ago`
39-
* @param {false | 'long' | 'short' | 'narrow'} opts.relativeTime Wether to display the timestamp as time from now (optional)
65+
* This is a composable wrapper around `formatRelativeTime` from `@nextcloud/l10n`.
66+
*
67+
* @param timestamp - The timestamp to format
68+
* @param opts - Formatting options
4069
*/
41-
export function useFormatDateTime(
42-
timestamp: MaybeRef<Date|number> = Date.now(),
43-
opts: MaybeRef<FormatDateOptions> = {},
44-
) {
45-
// Current time as Date.now is not reactive
46-
const currentTime = ref(Date.now())
47-
// The interval ID for the window
48-
let intervalId: number|undefined
49-
50-
const options = ref({
51-
format: {
52-
timeStyle: 'medium',
53-
dateStyle: 'short',
54-
} as Intl.DateTimeFormatOptions,
55-
relativeTime: 'long' as const,
56-
ignoreSeconds: false,
57-
...toValue(opts),
58-
})
59-
const wrappedOptions = computed<Required<FormatDateOptions>>(() => ({ ...toValue(opts), ...options.value }))
70+
export function useFormatRelativeTime(
71+
timestamp: MaybeRefOrGetter<Date | number> = Date.now(),
72+
opts: MaybeRefOrGetter<FormatRelativeTimeOptions> = {},
73+
): Readonly<Ref<string>> {
74+
let timeoutId: number
6075

61-
/** ECMA Date object of the timestamp */
76+
/**
77+
* ECMA Date object of the timestamp
78+
*/
6279
const date = computed(() => new Date(toValue(timestamp)))
6380

64-
const formattedFullTime = computed<string>(() => {
65-
const formatter = new Intl.DateTimeFormat(getCanonicalLocale(), wrappedOptions.value.format)
66-
return formatter.format(date.value)
81+
/**
82+
* Reactive options for `formatRelativeTime` method
83+
*/
84+
const options = computed<FormatDateOptions>(() => {
85+
const { language, relativeTime, ignoreSeconds } = toValue(opts)
86+
return {
87+
...language && { language },
88+
...relativeTime && { relativeTime },
89+
ignoreSeconds: ignoreSeconds
90+
? FEW_SECONDS_AGO[relativeTime || 'long']
91+
: false,
92+
}
6793
})
6894

69-
/** Time string formatted for main text */
70-
const formattedTime = computed<string>(() => {
71-
if (wrappedOptions.value.relativeTime !== false) {
72-
const formatter = new Intl.RelativeTimeFormat(getLanguage(), { numeric: 'auto', style: wrappedOptions.value.relativeTime })
73-
74-
const diff = date.value.getTime() - currentTime.value
75-
const seconds = diff / 1000
76-
if (Math.abs(seconds) <= 90) {
77-
if (wrappedOptions.value.ignoreSeconds) {
78-
return FEW_SECONDS_AGO[wrappedOptions.value.relativeTime]
79-
} else {
80-
return formatter.format(Math.round(seconds), 'second')
81-
}
82-
}
83-
const minutes = seconds / 60
84-
if (Math.abs(minutes) <= 90) {
85-
return formatter.format(Math.round(minutes), 'minute')
86-
}
87-
const hours = minutes / 60
88-
if (Math.abs(hours) <= 24) {
89-
return formatter.format(Math.round(hours), 'hour')
90-
}
91-
const days = hours / 24
92-
if (Math.abs(days) <= 6) {
93-
return formatter.format(Math.round(days), 'day')
94-
}
95-
const weeks = days / 7
96-
if (Math.abs(weeks) <= 4) {
97-
return formatter.format(Math.round(weeks), 'week')
98-
}
99-
const months = days / 30
100-
if (Math.abs(months) <= 12) {
101-
return formatter.format(Math.round(months), 'month')
102-
}
103-
return formatter.format(Math.round(days / 365), 'year')
95+
/**
96+
* The formatted relative time
97+
*/
98+
const relativeTime = ref('')
99+
watchEffect(() => updateRelativeTime())
100+
101+
/**
102+
* Update the relative time string.
103+
* This is the callback for the interval.
104+
*/
105+
function updateRelativeTime() {
106+
relativeTime.value = formatRelativeTime(date.value, options.value)
107+
108+
if (toValue(opts).update !== false) {
109+
const diff = Math.abs(Date.now() - new Date(toValue(timestamp)).getTime())
110+
const interval = diff > 120000 || options.value.ignoreSeconds
111+
? Math.min(diff / 60, 1800000)
112+
: 1000
113+
timeoutId = window.setTimeout(updateRelativeTime, interval)
104114
}
105-
return formattedFullTime.value
106-
})
115+
}
116+
117+
// when the component is unmounted we also clear the timeout
118+
onUnmounted(() => timeoutId && window.clearTimeout(timeoutId))
119+
120+
return readonly(relativeTime)
121+
}
122+
123+
/**
124+
* Format a given timestamp or date object as a human readable string.
125+
*
126+
* @param timestamp - Timestamp or date object to format
127+
* @param opts - Formatting options
128+
*/
129+
export function useFormatTime(
130+
timestamp: MaybeRefOrGetter<number | Date>,
131+
opts: MaybeRefOrGetter<FormatTimeOptions>,
132+
): Readonly<Ref<string>> {
133+
const options = computed<Required<FormatTimeOptions>>(() => ({
134+
locale: getCanonicalLocale(),
135+
format: { dateStyle: 'short', timeStyle: 'medium' },
136+
...toValue(opts),
137+
}))
138+
139+
const formatter = computed(() => new Intl.DateTimeFormat(options.value.locale, options.value.format))
140+
141+
return computed(() => formatter.value.format(toValue(timestamp)))
142+
}
107143

108-
// Set or clear interval if relative time is dis/enabled
109-
watchEffect(() => {
110-
window.clearInterval(intervalId)
111-
intervalId = undefined
112-
if (wrappedOptions.value.relativeTime) {
113-
intervalId = window.setInterval(() => { currentTime.value = Date.now() }, 1000)
144+
/**
145+
* Composable for formatting time stamps using current users locale and language
146+
*
147+
* @param {import('vue').MaybeRefOrGetter<Date | number>} timestamp Current timestamp
148+
* @param {object} opts Optional options
149+
* @param {Intl.DateTimeFormatOptions} opts.format The format used for displaying, or if relative time is used the format used for the title (optional)
150+
* @param {boolean} opts.ignoreSeconds Ignore seconds when displaying the relative time and just show `a few seconds ago`
151+
* @param {false | 'long' | 'short' | 'narrow'} opts.relativeTime Wether to display the timestamp as time from now (optional)
152+
*
153+
* @deprecated use `useFormatRelativeTime` or `useFormatTime` instead.
154+
*/
155+
export function useFormatDateTime(
156+
timestamp: MaybeRefOrGetter<Date|number> = Date.now(),
157+
opts: MaybeRefOrGetter<LegacyFormatDateTimeOptions> = {},
158+
) {
159+
const formattedFullTime = useFormatTime(timestamp, opts)
160+
const relativeTime = useFormatRelativeTime(timestamp, computed(() => {
161+
const options = toValue(opts)
162+
return {
163+
...options,
164+
relativeTime: typeof options.relativeTime === 'string'
165+
? options.relativeTime
166+
: 'long',
114167
}
115-
})
168+
}))
116169

117-
// ensure interval is cleared
118-
onUnmounted(() => {
119-
window.clearInterval(intervalId)
120-
})
170+
const formattedTime = computed(() => toValue(opts).relativeTime !== false
171+
? relativeTime.value
172+
: formattedFullTime.value,
173+
)
121174

122175
return {
123176
formattedTime,
124177
formattedFullTime,
125-
options,
126178
}
127179
}

tests/unit/components/NcDateTime/NcDateTime.spec.js

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,17 @@
44
*/
55

66
import { mount } from '@vue/test-utils'
7-
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
7+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
88
import NcDateTime from '../../../../src/components/NcDateTime/NcDateTime.vue'
99
import { nextTick } from 'vue'
1010

11+
const getCanonicalLocale = vi.hoisted(() => vi.fn(() => 'en-US'))
12+
13+
vi.mock('@nextcloud/l10n', async (original) => ({
14+
...(await original()),
15+
getCanonicalLocale,
16+
}))
17+
1118
describe('NcDateTime.vue', () => {
1219
'use strict'
1320

@@ -56,13 +63,8 @@ describe('NcDateTime.vue', () => {
5663
})
5764

5865
describe('Work with different locales', () => {
59-
beforeAll(() => {
60-
// mock the locale
61-
document.documentElement.dataset.locale = 'de_DE'
62-
})
63-
afterAll(() => {
64-
// revert mock
65-
document.documentElement.dataset.locale = 'en'
66+
beforeEach(() => {
67+
getCanonicalLocale.mockImplementationOnce(() => 'de-DE')
6668
})
6769

6870
/**
@@ -200,39 +202,5 @@ describe('NcDateTime.vue', () => {
200202

201203
expect(wrapper.element.textContent).toContain('3 weeks')
202204
})
203-
204-
it('shows months from now', () => {
205-
const time = Date.UTC(2023, 1, 23, 14, 30, 30)
206-
const currentTime = Date.UTC(2023, 6, 13, 14, 30, 30)
207-
vi.setSystemTime(currentTime)
208-
const wrapper = mount(NcDateTime, {
209-
props: {
210-
timestamp: time,
211-
},
212-
})
213-
214-
expect(wrapper.element.textContent).toContain('5 months')
215-
})
216-
217-
it('shows years from now', () => {
218-
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
219-
const time2 = Date.UTC(2022, 5, 23, 14, 30, 30)
220-
const currentTime = Date.UTC(2024, 6, 13, 14, 30, 30)
221-
vi.setSystemTime(currentTime)
222-
223-
const wrapper = mount(NcDateTime, {
224-
props: {
225-
timestamp: time,
226-
},
227-
})
228-
const wrapper2 = mount(NcDateTime, {
229-
props: {
230-
timestamp: time2,
231-
},
232-
})
233-
234-
expect(wrapper.element.textContent).toContain('last year')
235-
expect(wrapper2.element.textContent).toContain('2 years')
236-
})
237205
})
238206
})

0 commit comments

Comments
 (0)