Skip to content

Commit

Permalink
feat: add initial date formatting functions (#1279)
Browse files Browse the repository at this point in the history
* feat: add initial date formatting functions

* add export

* add japanese capitalization test

* cleanup dateRelativeToNow

* update function names to be more generic
  • Loading branch information
braddialpad authored Oct 25, 2023
1 parent c4afed8 commit f8340e9
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 0 deletions.
136 changes: 136 additions & 0 deletions common/dates.js
Original file line number Diff line number Diff line change
@@ -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');
}
}
100 changes: 100 additions & 0 deletions common/dates.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
11 changes: 11 additions & 0 deletions common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -399,4 +409,5 @@ export default {
isPhoneNumber,
isURL,
safeConcatStrings,
capitalizeFirstLetter,
};
13 changes: 13 additions & 0 deletions common/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isPhoneNumber,
isURL,
isEmailAddress,
capitalizeFirstLetter,
} from './utils';

describe('Util Tests', () => {
Expand Down Expand Up @@ -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('送り仮名');
});
});
});
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,5 @@ export {
filterFormattedMessages,
getValidationState,
} from './common/utils';

export * from './common/dates';

0 comments on commit f8340e9

Please sign in to comment.