diff --git a/src/components/NcDateTime/NcDateTime.vue b/src/components/NcDateTime/NcDateTime.vue
index 274772f309..9946007a52 100644
--- a/src/components/NcDateTime/NcDateTime.vue
+++ b/src/components/NcDateTime/NcDateTime.vue
@@ -113,14 +113,8 @@ h4 {
diff --git a/src/composables/index.js b/src/composables/index.js
index b5094166fc..d71757b732 100644
--- a/src/composables/index.js
+++ b/src/composables/index.js
@@ -22,3 +22,4 @@
export * from './useIsFullscreen/index.js'
export * from './useIsMobile/index.js'
+export * from './useFormatDateTime.js'
diff --git a/src/composables/useFormatDateTime.js b/src/composables/useFormatDateTime.js
new file mode 100644
index 0000000000..b8aecaea2c
--- /dev/null
+++ b/src/composables/useFormatDateTime.js
@@ -0,0 +1,130 @@
+/**
+ * @copyright Copyright (c) 2024 Ferdinand Thiessen
+ *
+ * @author Ferdinand Thiessen
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+import { getCanonicalLocale } from '@nextcloud/l10n'
+import { computed, onUnmounted, ref, onMounted, watch, unref } from 'vue'
+import { t } from '../l10n.js'
+
+const FEW_SECONDS_AGO = {
+ long: t('a few seconds ago'),
+ short: t('seconds ago'), // FOR TRANSLATORS: Shorter version of 'a few seconds ago'
+ narrow: t('sec. ago'), // FOR TRANSLATORS: If possible in your language an even shorter version of 'a few seconds ago'
+}
+
+/**
+ * Composable for formatting time stamps using current users locale
+ *
+ * @param {Date | number | import('vue').Ref | import('vue').Ref} timestamp Current timestamp
+ * @param {object} opts Optional options
+ * @param {Intl.DateTimeFormatOptions} opts.format The format used for displaying, or if relative time is used the format used for the title (optional)
+ * @param {boolean} opts.ignoreSeconds Ignore seconds when displaying the relative time and just show `a few seconds ago`
+ * @param {false | 'long' | 'short' | 'narrow'} opts.relativeTime Wether to display the timestamp as time from now (optional)
+ */
+export function useFormatDateTime(timestamp = Date.now(), opts = {}) {
+ // Current time as Date.now is not reactive
+ const currentTime = ref(Date.now())
+ // The interval ID for the window
+ let intervalId = null
+
+ const options = ref({
+ timeStyle: 'medium',
+ dateStyle: 'short',
+ relativeTime: 'long',
+ ignoreSeconds: false,
+ ...unref(opts),
+ })
+ const wrappedOptions = computed(() => ({ ...unref(opts), ...options.value }))
+
+ /** ECMA Date object of the timestamp */
+ const date = computed(() => new Date(unref(timestamp)))
+
+ const formattedFullTime = computed(() => {
+ const formatter = new Intl.DateTimeFormat(getCanonicalLocale(), wrappedOptions.value.format)
+ return formatter.format(date.value)
+ })
+
+ /** Time string formatted for main text */
+ const formattedTime = computed(() => {
+ if (wrappedOptions.value.relativeTime !== false) {
+ const formatter = new Intl.RelativeTimeFormat(getCanonicalLocale(), { numeric: 'auto', style: wrappedOptions.value.relativeTime })
+
+ const diff = date.value - currentTime.value
+ const seconds = diff / 1000
+ if (Math.abs(seconds) <= 90) {
+ if (wrappedOptions.value.ignoreSeconds) {
+ return FEW_SECONDS_AGO[wrappedOptions.value.relativeTime]
+ } else {
+ return formatter.format(Math.round(seconds), 'second')
+ }
+ }
+ const minutes = seconds / 60
+ if (Math.abs(minutes) <= 90) {
+ return formatter.format(Math.round(minutes), 'minute')
+ }
+ const hours = minutes / 60
+ if (Math.abs(hours) <= 24) {
+ return formatter.format(Math.round(hours), 'hour')
+ }
+ const days = hours / 24
+ if (Math.abs(days) <= 6) {
+ return formatter.format(Math.round(days), 'day')
+ }
+ const weeks = days / 7
+ if (Math.abs(weeks) <= 4) {
+ return formatter.format(Math.round(weeks), 'week')
+ }
+ const months = days / 30
+ if (Math.abs(months) <= 12) {
+ return formatter.format(Math.round(months), 'month')
+ }
+ return formatter.format(Math.round(days / 365), 'year')
+ }
+ return formattedFullTime
+ })
+
+ // Set or clear interval if relative time is dis/enabled
+ watch([wrappedOptions], (opts) => {
+ window.clearInterval(intervalId)
+ intervalId = undefined
+ if (opts.relativeTime) {
+ intervalId = window.setInterval(() => { currentTime.value = new Date() }, 1000)
+ }
+ })
+
+ // Start the interval for setting the current time if relative time is enabled
+ onMounted(() => {
+ if (wrappedOptions.value.relativeTime !== false) {
+ intervalId = window.setInterval(() => { currentTime.value = new Date() }, 1000)
+ }
+ })
+
+ // ensure interval is cleared
+ onUnmounted(() => {
+ window.clearInterval(intervalId)
+ })
+
+ return {
+ formattedTime,
+ formattedFullTime,
+ options,
+ }
+}
diff --git a/tests/unit/components/NcDateTime/NcDateTime.spec.js b/tests/unit/components/NcDateTime/NcDateTime.spec.js
index d9e6b46938..0cd58f1200 100644
--- a/tests/unit/components/NcDateTime/NcDateTime.spec.js
+++ b/tests/unit/components/NcDateTime/NcDateTime.spec.js
@@ -22,13 +22,17 @@
import { mount } from '@vue/test-utils'
import NcDateTime from '../../../../src/components/NcDateTime/NcDateTime.vue'
+import { nextTick } from 'vue'
describe('NcDateTime.vue', () => {
'use strict'
+ beforeAll(() => jest.useFakeTimers())
+ afterAll(() => jest.useRealTimers())
+
it('Sets the title property correctly', () => {
const time = Date.UTC(2023, 5, 23, 14, 30)
- Date.now = jest.fn(() => new Date(time).valueOf())
+ jest.setSystemTime(new Date(time))
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
@@ -41,7 +45,7 @@ describe('NcDateTime.vue', () => {
it('Can set format of the title property', () => {
const time = Date.UTC(2023, 5, 23, 14, 30)
- Date.now = jest.fn(() => new Date(time).valueOf())
+ jest.setSystemTime(new Date(time))
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
@@ -55,7 +59,7 @@ describe('NcDateTime.vue', () => {
it('Can disable relative time', () => {
const time = Date.UTC(2023, 5, 23, 14, 30)
- Date.now = jest.fn(() => new Date(time).valueOf())
+ jest.setSystemTime(new Date(time))
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
@@ -82,7 +86,7 @@ describe('NcDateTime.vue', () => {
*/
it('', () => {
const time = Date.UTC(2023, 5, 23, 14, 30)
- Date.now = jest.fn(() => new Date(time).valueOf())
+ jest.setSystemTime(time)
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
@@ -97,117 +101,107 @@ describe('NcDateTime.vue', () => {
describe('Shows relative time', () => {
it('works with currentTime == timestamp', () => {
const time = Date.UTC(2023, 5, 23, 14, 30)
- Date.now = jest.fn(() => new Date(time).valueOf())
+ jest.setSystemTime(new Date(time))
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
},
})
- expect(wrapper.vm.currentTime).toEqual(time)
expect(wrapper.element.textContent).toContain('now')
})
it('shows seconds from now (updating)', async () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
- let currentTime = Date.UTC(2023, 5, 23, 14, 30, 33)
- Date.now = jest.fn(() => new Date(currentTime).valueOf())
+ jest.setSystemTime(Date.UTC(2023, 5, 23, 14, 30, 33))
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
},
})
- expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('3 seconds')
- currentTime = Date.UTC(2023, 5, 23, 14, 30, 34)
- // wait for timer
- await new Promise((resolve) => setTimeout(resolve, 1100))
+ await jest.advanceTimersByTimeAsync(1020)
+ await nextTick()
expect(wrapper.element.textContent).toContain('4 seconds')
})
- it('shows seconds from now - also as short variant', () => {
+ it('shows seconds from now - also as narrow variant', () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
const currentTime = Date.UTC(2023, 5, 23, 14, 30, 33)
- Date.now = jest.fn(() => new Date(currentTime).valueOf())
+ jest.setSystemTime(currentTime)
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
- relativeTime: 'short',
+ relativeTime: 'narrow',
},
})
- expect(wrapper.vm.currentTime).toEqual(currentTime)
- expect(wrapper.element.textContent).toContain('3 sec.')
+ expect(wrapper.element.textContent).toContain('3s ago')
})
it('shows minutes from now', () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
const currentTime = Date.UTC(2023, 5, 23, 14, 33, 30)
- Date.now = jest.fn(() => new Date(currentTime).valueOf())
+ jest.setSystemTime(currentTime)
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
},
})
- expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('3 minutes')
})
it('shows hours from now', () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
const currentTime = Date.UTC(2023, 5, 23, 17, 30, 30)
- Date.now = jest.fn(() => new Date(currentTime).valueOf())
+ jest.setSystemTime(currentTime)
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
},
})
- expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('3 hours')
})
it('shows days from now', () => {
const time = Date.UTC(2023, 5, 21, 20, 30, 30)
const currentTime = Date.UTC(2023, 5, 23, 17, 30, 30)
- Date.now = jest.fn(() => new Date(currentTime).valueOf())
+ jest.setSystemTime(currentTime)
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
},
})
- expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('2 days')
})
it('shows weeks from now', () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
const currentTime = Date.UTC(2023, 6, 13, 14, 30, 30)
- Date.now = jest.fn(() => new Date(currentTime).valueOf())
+ jest.setSystemTime(currentTime)
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
},
})
- expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('3 weeks')
})
it('shows months from now', () => {
const time = Date.UTC(2023, 1, 23, 14, 30, 30)
const currentTime = Date.UTC(2023, 6, 13, 14, 30, 30)
- Date.now = jest.fn(() => new Date(currentTime).valueOf())
+ jest.setSystemTime(currentTime)
const wrapper = mount(NcDateTime, {
propsData: {
timestamp: time,
},
})
- expect(wrapper.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('5 months')
})
@@ -215,7 +209,7 @@ describe('NcDateTime.vue', () => {
const time = Date.UTC(2023, 5, 23, 14, 30, 30)
const time2 = Date.UTC(2022, 5, 23, 14, 30, 30)
const currentTime = Date.UTC(2024, 6, 13, 14, 30, 30)
- Date.now = jest.fn(() => new Date(currentTime).valueOf())
+ jest.setSystemTime(currentTime)
const wrapper = mount(NcDateTime, {
propsData: {
@@ -228,8 +222,6 @@ describe('NcDateTime.vue', () => {
},
})
- expect(wrapper.vm.currentTime).toEqual(currentTime)
- expect(wrapper2.vm.currentTime).toEqual(currentTime)
expect(wrapper.element.textContent).toContain('last year')
expect(wrapper2.element.textContent).toContain('2 years')
})
diff --git a/tests/unit/composables/useFormatDateTime.spec.js b/tests/unit/composables/useFormatDateTime.spec.js
new file mode 100644
index 0000000000..4e422befc2
--- /dev/null
+++ b/tests/unit/composables/useFormatDateTime.spec.js
@@ -0,0 +1,86 @@
+/**
+ * @copyright 2024 Ferdinand Thiessen
+ *
+ * @author Ferdinand Thiessen
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+import { useFormatDateTime } from '../../../src/composables/useFormatDateTime.js'
+import { isRef, nextTick, ref } from 'vue'
+
+describe('useFormatDateTime composable', () => {
+ beforeAll(() => {
+ jest.spyOn(console, 'error').mockImplementation(() => {})
+ })
+ afterAll(() => {
+ jest.restoreAllMocks()
+ })
+
+ it('Should provide formatted time and options as ref', () => {
+ const ctx = useFormatDateTime()
+ expect(isRef(ctx.formattedTime)).toBe(true)
+ expect(isRef(ctx.formattedFullTime)).toBe(true)
+ expect(isRef(ctx.options)).toBe(true)
+ })
+
+ it('Shows relative times', async () => {
+ const time = ref(Date.now())
+ const ctx = useFormatDateTime(time, { ignoreSeconds: false, relativeTime: 'long' })
+ expect(ctx.formattedTime.value).toContain('now')
+ time.value = Date.now() - 5000
+ await nextTick()
+ expect(ctx.formattedTime.value).toMatch(/\d sec/)
+ time.value = Date.now() - 120000
+ await nextTick()
+ expect(ctx.formattedTime.value).toContain('2 minutes')
+ time.value = Date.now() - 2 * 60 * 60 * 1000
+ await nextTick()
+ expect(ctx.formattedTime.value).toContain('2 hours')
+ time.value = Date.now() - 2 * 24 * 60 * 60 * 1000
+ await nextTick()
+ expect(ctx.formattedTime.value).toContain('2 days')
+ time.value = Date.now() - 2 * 7 * 24 * 60 * 60 * 1000
+ await nextTick()
+ expect(ctx.formattedTime.value).toContain('2 weeks')
+ time.value = Date.now() - 2 * 30 * 24 * 60 * 60 * 1000
+ await nextTick()
+ expect(ctx.formattedTime.value).toContain('2 month')
+ time.value = Date.now() - 2 * 365 * 24 * 60 * 60 * 1000
+ await nextTick()
+ expect(ctx.formattedTime.value).toContain('2 years')
+ })
+
+ it('Shows different relative times', async () => {
+ const ctx = useFormatDateTime(Date.now() - 5000, { ignoreSeconds: true, relativeTime: 'long' })
+ expect(ctx.formattedTime.value).toBe('a few seconds ago')
+ ctx.options.value.relativeTime = 'short'
+ await nextTick()
+ expect(ctx.formattedTime.value).toBe('seconds ago')
+ ctx.options.value.relativeTime = 'narrow'
+ await nextTick()
+ expect(ctx.formattedTime.value).toBe('sec. ago')
+ })
+
+ it('Should be reactive on options change', async () => {
+ const ctx = useFormatDateTime(Date.now() - 5000, { ignoreSeconds: false })
+ expect(ctx.formattedTime.value).toContain('sec')
+ ctx.options.value.ignoreSeconds = true
+ await nextTick()
+ expect(ctx.formattedTime.value).toBe('a few seconds ago')
+ })
+})