diff --git a/src/index.ts b/src/index.ts index 910ca988..c0cea395 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,12 @@ export type { } from './hooks/use-toast-messages'; // Utils +export { + nextFuzzyUpdate, + decayingInterval, + formatRelativeDate, + formatDateTime, +} from './util/date-and-time'; export { confirm } from './util/prompts'; // Components @@ -146,4 +152,9 @@ export type { TabListProps, } from './components/navigation/'; +export type { + Breakpoint, + DateFormatter, + FormatDateTimeOptions, +} from './util/date-and-time'; export type { ConfirmModalProps } from './util/prompts'; diff --git a/src/util/date-and-time.ts b/src/util/date-and-time.ts new file mode 100644 index 00000000..f124a349 --- /dev/null +++ b/src/util/date-and-time.ts @@ -0,0 +1,278 @@ +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; + +/** + * Map of stringified `DateTimeFormatOptions` to cached `DateTimeFormat` instances. + */ +let formatters = new Map(); + +/** + * Clears the cache of formatters. + */ +export function clearFormatters() { + formatters = new Map(); +} + +type IntlType = typeof window.Intl; + +/** + * Calculate time delta in milliseconds between two `Date` objects + */ +function delta(date: Date, now: Date) { + // @ts-ignore + return now - date; +} + +/** + * Return date string formatted with `options`. + * + * This is a caching wrapper for `Intl.DateTimeFormat.format`, useful because + * constructing a `DateTimeFormat` is expensive. + * + * @param Intl - Test seam. JS `Intl` API implementation. + */ +function format( + date: Date, + options: Intl.DateTimeFormatOptions, + /* istanbul ignore next */ + Intl: IntlType = window.Intl, +): string { + const key = JSON.stringify(options); + let formatter = formatters.get(key); + if (!formatter) { + formatter = new Intl.DateTimeFormat(undefined, options); + formatters.set(key, formatter); + } + return formatter.format(date); +} + +/** + * @return formatted date + */ +export type DateFormatter = (date: Date, now: Date, intl?: IntlType) => string; + +const nSec: DateFormatter = (date, now) => { + const n = Math.floor(delta(date, now) / SECOND); + return `${n} secs ago`; +}; + +const nMin: DateFormatter = (date, now) => { + const n = Math.floor(delta(date, now) / MINUTE); + const plural = n > 1 ? 's' : ''; + return `${n} min${plural} ago`; +}; + +const nHr: DateFormatter = (date, now) => { + const n = Math.floor(delta(date, now) / HOUR); + const plural = n > 1 ? 's' : ''; + return `${n} hr${plural} ago`; +}; + +const dayAndMonth: DateFormatter = (date, now, Intl) => { + return format(date, { month: 'short', day: 'numeric' }, Intl); +}; + +const dayAndMonthAndYear: DateFormatter = (date, now, Intl) => { + return format( + date, + { day: 'numeric', month: 'short', year: 'numeric' }, + Intl, + ); +}; + +export type Breakpoint = { + test: (date: Date, now: Date) => boolean; + formatter: DateFormatter; + nextUpdate: number | null; +}; + +const BREAKPOINTS: Breakpoint[] = [ + { + // Less than 30 seconds + test: (date, now) => delta(date, now) < 30 * SECOND, + formatter: () => 'Just now', + nextUpdate: 1 * SECOND, + }, + { + // Less than 1 minute + test: (date, now) => delta(date, now) < 1 * MINUTE, + formatter: nSec, + nextUpdate: 1 * SECOND, + }, + { + // Less than one hour + test: (date, now) => delta(date, now) < 1 * HOUR, + formatter: nMin, + nextUpdate: 1 * MINUTE, + }, + { + // Less than one day + test: (date, now) => delta(date, now) < 24 * HOUR, + formatter: nHr, + nextUpdate: 1 * HOUR, + }, + { + // This year + test: (date, now) => date.getFullYear() === now.getFullYear(), + formatter: dayAndMonth, + nextUpdate: null, + }, +]; + +const DEFAULT_BREAKPOINT: Breakpoint = { + test: /* istanbul ignore next */ () => true, + formatter: dayAndMonthAndYear, + nextUpdate: null, +}; + +/** + * Returns a dict that describes how to format the date based on the delta + * between date and now. + * + * @param date - The date to consider as the timestamp to format. + * @param now - The date to consider as the current time. + * @return An object that describes how to format the date. + */ +function getBreakpoint(date: Date, now: Date): Breakpoint { + for (const breakpoint of BREAKPOINTS) { + if (breakpoint.test(date, now)) { + return breakpoint; + } + } + return DEFAULT_BREAKPOINT; +} + +/** + * Determines if provided date represents a specific instant of time. + * See https://262.ecma-international.org/6.0/#sec-time-values-and-time-range + */ +function isDateValid(date: Date): boolean { + return !isNaN(date.valueOf()); +} + +/** + * Return the number of milliseconds until the next update for a given date + * should be handled, based on the delta between `date` and `now`. + * + * @return ms until next update or `null` if no update should occur + */ +export function nextFuzzyUpdate(date: Date | null, now: Date): number | null { + if (!date || !isDateValid(date) || !isDateValid(now)) { + return null; + } + + let nextUpdate = getBreakpoint(date, now).nextUpdate; + + if (nextUpdate === null) { + return null; + } + + // We don't want to refresh anything more often than 5 seconds + nextUpdate = Math.max(nextUpdate, 5 * SECOND); + + // setTimeout limit is MAX_INT32=(2^31-1) (in ms), + // which is about 24.8 days. So we don't set up any timeouts + // longer than 24 days, that is, 2073600 seconds. + nextUpdate = Math.min(nextUpdate, 2073600 * SECOND); + + return nextUpdate; +} + +/** + * Start an interval whose frequency depends on the age of a timestamp. + * + * This is useful for refreshing UI components displaying timestamps generated + * by `formatRelativeDate`, since the output changes less often for older timestamps. + * + * @param date - Date string to use to determine the interval frequency + * @param callback - Interval callback + * @return A function that cancels the interval + */ +export function decayingInterval( + date: string, + callback: () => void, +): () => void { + let timer: number | undefined; + const timestamp = new Date(date); + + const update = () => { + const fuzzyUpdate = nextFuzzyUpdate(timestamp, new Date()); + if (fuzzyUpdate === null) { + return; + } + const nextUpdate = fuzzyUpdate + 500; + timer = setTimeout(() => { + callback(); + update(); + }, nextUpdate); + }; + + update(); + + return () => clearTimeout(timer); +} + +/** + * Formats a date as a short approximate string relative to the current date. + * + * The level of precision is proportional to how recent the date is. + * + * For example: + * + * - "Just now" + * - "5 minutes ago" + * - "25 Oct 2018" + * + * @param date - The date to consider as the timestamp to format. + * @param now - The date to consider as the current time. + * @param Intl - Test seam. JS `Intl` API implementation. + * @return A 'fuzzy' string describing the relative age of the date. + */ +export function formatRelativeDate( + date: Date | null, + now: Date, + Intl?: IntlType, +): string { + if (!date) { + return ''; + } + return getBreakpoint(date, now).formatter(date, now, Intl); +} + +export type FormatDateTimeOptions = { + /** + * Whether the formatted date should include the week day or not. + * Defaults to `false`. + */ + includeWeekday?: boolean; + + /** Test seam. JS `Intl` API implementation. */ + Intl?: IntlType; +}; + +/** + * Formats a date as an absolute string in a human-readable format. + * + * The exact format will vary depending on the locale, but the verbosity will + * be consistent across locales. In en-US for example this will look like: + * + * "Dec 17, 2017, 10:00 AM" + */ +export function formatDateTime( + date: Date | string, + options?: FormatDateTimeOptions, +): string { + return format( + typeof date === 'string' ? new Date(date) : date, + { + year: 'numeric', + month: 'short', + day: '2-digit', + weekday: options?.includeWeekday ? 'long' : undefined, + hour: '2-digit', + minute: '2-digit', + }, + options?.Intl, + ); +} diff --git a/src/util/test/date-and-time-test.js b/src/util/test/date-and-time-test.js new file mode 100644 index 00000000..ac799d01 --- /dev/null +++ b/src/util/test/date-and-time-test.js @@ -0,0 +1,279 @@ +import { + clearFormatters, + decayingInterval, + formatDateTime, + formatRelativeDate, + nextFuzzyUpdate, +} from '../date-and-time'; + +const second = 1000; +const minute = second * 60; +const hour = minute * 60; +const day = hour * 24; + +describe('date-and-time', () => { + let sandbox; + let fakeIntl; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.useFakeTimers(); + + fakeIntl = { + DateTimeFormat: sinon.stub().returns({ + format: sinon.stub(), + }), + }; + // Clear the formatters cache so that mocked formatters + // from one test run don't affect the next. + clearFormatters(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + const fakeDate = isoString => { + // Since methods like Date.getFullYear output the year in + // whatever timezone the node timezone is set to, these + // methods must be mocked/mapped to their UTC equivalents when + // testing such as getUTCFullYear in order to have timezone + // agnostic tests. + // Example: + // An annotation was posted at 2019-01-01T01:00:00 UTC and now the + // current date is a few days later; 2019-01-10. + // - A user in the UK who views the annotation will see “Jan 1” + // on the annotation card (correct) + // - A user in San Francisco who views the annotation will see + // “Dec 31, 2018" on the annotation card (also correct from + // their point of view). + const date = new Date(isoString); + date.getFullYear = sinon.stub().returns(date.getUTCFullYear()); + return date; + }; + + describe('formatRelativeDate', () => { + it('handles empty dates', () => { + const date = null; + const expect = ''; + assert.equal(formatRelativeDate(date, undefined), expect); + }); + + [ + { now: '1970-01-01T00:00:10.000Z', text: 'Just now' }, + { now: '1970-01-01T00:00:29.000Z', text: 'Just now' }, + { now: '1970-01-01T00:00:49.000Z', text: '49 secs ago' }, + { now: '1970-01-01T00:01:05.000Z', text: '1 min ago' }, + { now: '1970-01-01T00:03:05.000Z', text: '3 mins ago' }, + { now: '1970-01-01T01:00:00.000Z', text: '1 hr ago' }, + { now: '1970-01-01T04:00:00.000Z', text: '4 hrs ago' }, + ].forEach(test => { + it('creates correct fuzzy string for fixture ' + test.now, () => { + const timeStamp = fakeDate('1970-01-01T00:00:00.000Z'); + const now = fakeDate(test.now); + assert.equal(formatRelativeDate(timeStamp, now), test.text); + }); + }); + + [ + { + now: '1970-01-02T03:00:00.000Z', + text: '2 Jan', + options: { day: 'numeric', month: 'short' }, + }, + { + now: '1970-01-04T00:30:00.000Z', + text: '4 Jan', + options: { day: 'numeric', month: 'short' }, + }, + { + now: '1970-07-03T00:00:00.000Z', + text: '3 July', + options: { day: 'numeric', month: 'short' }, + }, + { + now: '1971-01-01T00:00:00.000Z', + text: '1 Jan 1970', + options: { day: 'numeric', month: 'short', year: 'numeric' }, + }, + { + now: '1971-03-01T00:00:00.000Z', + text: '1 Jan 1970', + options: { day: 'numeric', month: 'short', year: 'numeric' }, + }, + { + now: '1972-01-01T00:00:00.000Z', + text: '1 Jan 1970', + options: { day: 'numeric', month: 'short', year: 'numeric' }, + }, + { + now: '1978-01-01T00:00:00.000Z', + text: '1 Jan 1970', + options: { day: 'numeric', month: 'short', year: 'numeric' }, + }, + ].forEach(test => { + it( + 'passes correct arguments to `Intl.DateTimeFormat.format` for fixture ' + + test.now, + () => { + const timeStamp = fakeDate('1970-01-01T00:00:00.000Z'); + const now = fakeDate(test.now); + + fakeIntl.DateTimeFormat().format.returns(test.text); // eslint-disable-line new-cap + assert.equal(formatRelativeDate(timeStamp, now, fakeIntl), test.text); + assert.calledWith(fakeIntl.DateTimeFormat, undefined, test.options); + assert.calledWith(fakeIntl.DateTimeFormat().format, timeStamp); // eslint-disable-line new-cap + }, + ); + }); + }); + + describe('decayingInterval', () => { + it('handles empty dates', () => { + const date = null; + decayingInterval(date, undefined); + }); + + it('never invokes callback if date is invalid', () => { + const date = new Date('foo bar'); + const callback = sinon.stub(); + + decayingInterval(date, callback); + sandbox.clock.tick(600 * day); + + assert.notCalled(callback); + }); + + it('uses a short delay for recent timestamps', () => { + const date = new Date().toISOString(); + const callback = sandbox.stub(); + decayingInterval(date, callback); + sandbox.clock.tick(6 * second); + assert.called(callback); + sandbox.clock.tick(6 * second); + assert.calledTwice(callback); + }); + + it('uses a longer delay for older timestamps', () => { + const date = new Date().toISOString(); + const ONE_MINUTE = minute; + sandbox.clock.tick(10 * ONE_MINUTE); + const callback = sandbox.stub(); + decayingInterval(date, callback); + sandbox.clock.tick(ONE_MINUTE / 2); + assert.notCalled(callback); + sandbox.clock.tick(ONE_MINUTE); + assert.called(callback); + sandbox.clock.tick(ONE_MINUTE); + assert.calledTwice(callback); + }); + + it('returns a callback that cancels the timer', () => { + const date = new Date().toISOString(); + const callback = sandbox.stub(); + const cancel = decayingInterval(date, callback); + cancel(); + sandbox.clock.tick(minute); + assert.notCalled(callback); + }); + + it('does not set a timeout for dates > 24hrs ago', () => { + const date = new Date().toISOString(); + const ONE_DAY = day; + sandbox.clock.tick(10 * ONE_DAY); + const callback = sandbox.stub(); + + decayingInterval(date, callback); + sandbox.clock.tick(ONE_DAY * 2); + + assert.notCalled(callback); + }); + }); + + describe('nextFuzzyUpdate', () => { + it('handles empty dates', () => { + const date = null; + const expect = null; + assert.equal(nextFuzzyUpdate(date, undefined), expect); + }); + + it('returns `null` if date is invalid', () => { + const date = new Date('foo bar'); + assert.equal(nextFuzzyUpdate(date), null); + }); + + it('returns `null` if "now" date is invalid', () => { + const date = new Date(); + const now = new Date('foo bar'); + assert.equal(nextFuzzyUpdate(date, now), null); + }); + + [ + { now: '1970-01-01T00:00:10.000Z', expectedUpdateTime: 5 * second }, // we have a minimum of 5 secs + { now: '1970-01-01T00:00:20.000Z', expectedUpdateTime: 5 * second }, + { now: '1970-01-01T00:00:49.000Z', expectedUpdateTime: 5 * second }, + { now: '1970-01-01T00:01:05.000Z', expectedUpdateTime: minute }, + { now: '1970-01-01T00:03:05.000Z', expectedUpdateTime: minute }, + { now: '1970-01-01T04:00:00.000Z', expectedUpdateTime: hour }, + { now: '1970-01-02T03:00:00.000Z', expectedUpdateTime: null }, + { now: '1970-01-04T00:30:00.000Z', expectedUpdateTime: null }, + { now: '1970-07-02T00:00:00.000Z', expectedUpdateTime: null }, + { now: '1978-01-01T00:00:00.000Z', expectedUpdateTime: null }, + ].forEach(test => { + it('gives correct next fuzzy update time for fixture ' + test.now, () => { + const timeStamp = fakeDate('1970-01-01T00:00:00.000Z'); + const now = fakeDate(test.now); + assert.equal(nextFuzzyUpdate(timeStamp, now), test.expectedUpdateTime); + }); + }); + }); + + describe('formatDateTime', () => { + // Normalize "special" spaces (eg. "\u202F") to standard spaces. + function normalizeSpaces(str) { + return str.normalize('NFKC'); + } + + [ + { + locale: 'en-US', + date: new Date('2020-05-04T23:02:01'), + includeWeekday: true, + expectedResult: 'Monday, May 04, 2020, 11:02 PM', + }, + { + locale: 'de-DE', + date: new Date('2020-05-04T23:02:01'), + includeWeekday: true, + expectedResult: 'Montag, 04. Mai 2020, 23:02', + }, + { + locale: 'en-US', + date: '2020-05-04T23:02:01', + includeWeekday: false, + expectedResult: 'May 04, 2020, 11:02 PM', + }, + { + locale: 'de-DE', + date: '2020-05-04T23:02:01', + includeWeekday: false, + expectedResult: '04. Mai 2020, 23:02', + }, + ].forEach(({ locale, includeWeekday, expectedResult, date }) => { + it('returns absolute formatted date', () => { + const fakeIntl = locale => ({ + DateTimeFormat: function (_, options) { + return new Intl.DateTimeFormat(locale, options); + }, + }); + + assert.equal( + normalizeSpaces( + formatDateTime(date, { Intl: fakeIntl(locale), includeWeekday }), + ), + expectedResult, + ); + }); + }); + }); +});