diff --git a/common/dates.js b/common/dates.js new file mode 100644 index 0000000000..a8d2de6221 --- /dev/null +++ b/common/dates.js @@ -0,0 +1,136 @@ +import { + format, + formatDuration, + intervalToDuration, + formatDistance, + isToday, + isYesterday, + isThisWeek, + isThisYear, +} from 'date-fns'; +import { capitalizeFirstLetter } from './utils'; + +// Base functions just wrap core date-fns functions, but this allows us to do checks and set default options. + +function _baseFormat (date, formatString) { + _checkLocaleSet(); + return format(date, formatString, { + locale: global.locale, + }); +} + +function _baseFormatDuration (duration, formatString) { + _checkLocaleSet(); + return formatDuration(duration, { + locale: global.locale, + format: formatString, + }); +} + +function _baseFormatDistance (date, baseDate) { + _checkLocaleSet(); + return formatDistance(date, baseDate, { + locale: global.locale, + }); +} + +function _isLocaleSet () { + return global.locale !== undefined; +} + +function _checkLocaleSet () { + if (!_isLocaleSet()) { + throw new Error('Locale not set, please call setDateLocale(locale) and pass ' + + 'in a datefns locale object as the locale param before calling this function'); + } +} + +/** + * Sets the locale for date-fns. This should be called before any date-fns functions are called. + * @param {Locale} locale A date-fns locale object + */ +export function setDateLocale (locale) { + global.locale = locale; +} + +/** + * This formats a date to the Dialtone standard medium date format as shown here: + * https://dialpad.design/guides/writing-guidelines/#formats-by-length + * @param {Date} date A javascript date object + * @returns {string} A string in the format of 'September 2, 2022' + */ +export function getDateMedium (date) { + return _baseFormat(date, 'MMMM d, y'); +} + +/** + * Converts a call duration in total number of seconds to a human readable string + * such as 'less than a minute' or '4 hours 34 minutes'. + * @param {number} durationInSeconds The duration of the call in seconds + * @returns {string} A human readable string representing the duration of the call + */ +export function durationInHHMM (durationInSeconds) { + if (durationInSeconds < 60) { + // returns 'less than a minute', we're doing it like this instead of returning a string + // so datefns handles i18n. + return _baseFormatDistance(0, 29 * 1000); + } + const duration = intervalToDuration({ + start: 0, + end: durationInSeconds * 1000, + }); + return _baseFormatDuration(duration, ['hours', 'minutes']); +} + +/** + * gets the human readable name of the day relative to the current time. For example, if you pass in -1 it will + * say "Yesterday" if you pass in 0 it will say "Today", if you pass in 1 it will say "Tomorrow". + * @param {number} days The number of days relative to the current time + * @returns {string} A human readable string representing the distance between the date and now + */ +function _getRelativeDaysText (days) { + const rtl = new Intl.RelativeTimeFormat(global.locale.code, { numeric: 'auto' }); + return capitalizeFirstLetter(rtl.formatToParts(days, 'day')[0].value, global.locale.code); +} + +/** + * Returns the distance between the passed in date and now in a human readable format, typically used + * when showing a history of items in a log such as a feed list. + * + * datefns does not support 'today' and 'yesterday' without showing time so we use Intl for these cases. + * + * examples below to explain + * the different potential formats: + * + * If current day: + * Today + * + * If previous day: + * Yesterday + * + * Older than yesterday, but in the same calendar week: + * Monday + * + * Older than the most recent calendar week, but in the same year: + * Monday, October 14 + * + * older than a calendar year: + * October 14, 2022 + * + * + * @param {Date} date The timestamp of the item's date + * @returns {string} A human readable string representing the distance between the date and now + */ +export function relativeDate (date) { + if (isToday(date)) { + return _getRelativeDaysText(0); + } else if (isYesterday(date)) { + return _getRelativeDaysText(-1); + } else if (isThisWeek(date)) { + return _baseFormat(date, 'EEEE'); + } else if (isThisYear(date)) { + return _baseFormat(date, 'EEEE, MMMM d'); + } else { + return _baseFormat(date, 'MMMM d, y'); + } +} diff --git a/common/dates.test.js b/common/dates.test.js new file mode 100644 index 0000000000..a1bcf8653e --- /dev/null +++ b/common/dates.test.js @@ -0,0 +1,100 @@ +import { beforeEach, vi } from 'vitest'; +import { es, enUS } from 'date-fns/locale'; +import { + setDateLocale, + getDateMedium, + durationInHHMM, + relativeDate, +} from './dates'; + +const testInputDate = new Date(2022, 8, 2); + +describe('Date function tests', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('When locale is not set', () => { + it('getDateMedium throws an error', () => { + global.locale = undefined; + expect(() => getDateMedium(testInputDate)).toThrow(); + }); + }); + + describe('When locale is set to enUS', () => { + beforeEach(() => { + setDateLocale(enUS); + }); + + it('getDateMedium returns the expected date', () => { + expect(getDateMedium(testInputDate)).toBe('September 2, 2022'); + }); + + it.each([ + [new Date(2023, 9, 24, 10, 0, 0), null, 'Today'], + [new Date(2023, 9, 23, 10, 0, 0), null, 'Yesterday'], + [new Date(2023, 9, 22, 10, 0, 0), null, 'Sunday'], + [new Date(2023, 9, 14, 10, 0, 0), null, 'Saturday, October 14'], + [new Date(2022, 9, 14, 10, 0, 0), null, 'October 14, 2022'], + [new Date(2022, 11, 31, 23, 59, 59), new Date(2023, 0, 1, 0, 0, 0), 'Yesterday'], + ])('When %s is passed in to relativeDate, it returns %s', (inputDate, currentTime, expected) => { + if (currentTime === null) { + // Set current time to Oct 24 2023 10:30:00 AM by default. + currentTime = new Date(2023, 9, 24, 10, 30, 0); + } + vi.setSystemTime(currentTime); + + expect(relativeDate(inputDate)).toBe(expected); + }); + + it.each([ + [59, 'less than a minute'], + [29, 'less than a minute'], + [0, 'less than a minute'], + [60, '1 minute'], + [55 * 60, '55 minutes'], + [(4 * 60 * 60) + (34 * 60), '4 hours 34 minutes'], + ])('When %d is passed in to durationInHHMM, it returns %s', (inputSeconds, expected) => { + expect(durationInHHMM(inputSeconds)).toBe(expected); + }); + }); + + /** + * Most functions can just be tested using just the enUS locale, but we should test a few language based + * formats here just to make sure that i18n is working as expected. + */ + + describe('When locale is set to es', () => { + beforeEach(() => { + setDateLocale(es); + }); + + it('getDateMedium returns the expected date', () => { + expect(getDateMedium(testInputDate)).toBe('septiembre 2, 2022'); + }); + + it.each([ + [new Date(2023, 9, 24, 10, 0, 0), null, 'Hoy'], + [new Date(2023, 9, 14, 10, 0, 0), null, 'sábado, octubre 14'], + ])('When %s is passed in to relativeDate, it returns %s', (inputDate, currentTime, expected) => { + if (currentTime === null) { + // Set current time to Oct 24 2023 10:30:00 AM by default. + currentTime = new Date(2023, 9, 24, 10, 30, 0); + } + vi.setSystemTime(currentTime); + + expect(relativeDate(inputDate)).toBe(expected); + }); + + it.each([ + [59, 'menos de un minuto'], + [(4 * 60 * 60) + (34 * 60), '4 horas 34 minutos'], + ])('When %d is passed in to durationInHHMM, it returns %s', (inputSeconds, expected) => { + expect(durationInHHMM(inputSeconds)).toBe(expected); + }); + }); +}); diff --git a/common/utils.js b/common/utils.js index 70cf22366f..615eee730d 100644 --- a/common/utils.js +++ b/common/utils.js @@ -379,6 +379,16 @@ export function safeConcatStrings (elements) { return elements.filter(str => !!str).join(' '); } +/** + * Locale safe function to capitalize the first letter of a string. + * @param {string} str the string to capitalize the first letter of + * @param {string} locale a string representing the locale to be used. Defaults to 'en-US' + * @returns The passed in string with the first letter capitalized + */ +export function capitalizeFirstLetter (str, locale = 'en-US') { + return str.replace(/^\p{CWU}/u, char => char.toLocaleUpperCase(locale)); +} + export default { getUniqueString, getRandomElement, @@ -399,4 +409,5 @@ export default { isPhoneNumber, isURL, safeConcatStrings, + capitalizeFirstLetter, }; diff --git a/common/utils.test.js b/common/utils.test.js index 7bcd9906ab..76672e2545 100644 --- a/common/utils.test.js +++ b/common/utils.test.js @@ -13,6 +13,7 @@ import { isPhoneNumber, isURL, isEmailAddress, + capitalizeFirstLetter, } from './utils'; describe('Util Tests', () => { @@ -475,4 +476,16 @@ describe('Util Tests', () => { }); }); }); + + describe('capitalizeFirstLetter', () => { + it('should capitalize the first letter of a string', () => { + expect(capitalizeFirstLetter('hello')).toBe('Hello'); + }); + it('should capitalize the first letter of a string with dutch locale', () => { + expect(capitalizeFirstLetter('ijsselmeer', 'nl')).toBe('IJsselmeer'); + }); + it('should not change japanese since it does not have capitals', () => { + expect(capitalizeFirstLetter('送り仮名', 'ja')).toBe('送り仮名'); + }); + }); }); diff --git a/index.js b/index.js index 213e9729a0..41faf7a84c 100644 --- a/index.js +++ b/index.js @@ -88,3 +88,5 @@ export { filterFormattedMessages, getValidationState, } from './common/utils'; + +export * from './common/dates';