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') + }) +})