diff --git a/docs/pages/docs/usage/dates-times.mdx b/docs/pages/docs/usage/dates-times.mdx index 915e23d99..aa305aaf5 100644 --- a/docs/pages/docs/usage/dates-times.mdx +++ b/docs/pages/docs/usage/dates-times.mdx @@ -74,6 +74,21 @@ function Component() { Note that values are rounded, so e.g. if 100 seconds have passed, "2 minutes ago" will be returned. +If you want to use a specific unit, you can pass options with the second argument: + +```js +import {useFormatter} from 'next-intl'; + +function Component() { + const format = useFormatter(); + const dateTime = new Date('2020-11-20T08:30:00.000Z'); + const now = new Date('2020-11-20T10:36:00.000Z'); + + // Renders "today" + format.relativeTime(dateTime, { now, unit: 'day' }); +} +``` + Supplying `now` is necessary for the function to return consistent results. If you have [configured a global value for `now` on the provider](/docs/configuration#global-now-value), you can omit the second argument: ```js diff --git a/packages/use-intl/src/core/RelativeTimeFormatOptions.tsx b/packages/use-intl/src/core/RelativeTimeFormatOptions.tsx new file mode 100644 index 000000000..a4f98d3ba --- /dev/null +++ b/packages/use-intl/src/core/RelativeTimeFormatOptions.tsx @@ -0,0 +1,6 @@ +type RelativeTimeFormatOptions = { + now?: number | Date; + unit?: Intl.RelativeTimeFormatUnit; +}; + +export default RelativeTimeFormatOptions; diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 515b6b8f5..ac6bfc35f 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -2,47 +2,64 @@ import DateTimeFormatOptions from './DateTimeFormatOptions'; import Formats from './Formats'; import IntlError, {IntlErrorCode} from './IntlError'; import NumberFormatOptions from './NumberFormatOptions'; +import RelativeTimeFormatOptions from './RelativeTimeFormatOptions'; import TimeZone from './TimeZone'; import {defaultOnError} from './defaults'; -const MINUTE = 60; +const SECOND = 1; +const MINUTE = SECOND * 60; const HOUR = MINUTE * 60; const DAY = HOUR * 24; const WEEK = DAY * 7; const MONTH = DAY * (365 / 12); // Approximation +const QUARTER = MONTH * 3; const YEAR = DAY * 365; -function getRelativeTimeFormatConfig(seconds: number) { +const UNIT_SECONDS: Record = { + second: SECOND, + seconds: SECOND, + minute: MINUTE, + minutes: MINUTE, + hour: HOUR, + hours: HOUR, + day: DAY, + days: DAY, + week: WEEK, + weeks: WEEK, + month: MONTH, + months: MONTH, + quarter: QUARTER, + quarters: QUARTER, + year: YEAR, + years: YEAR +} as const; + +function resolveRelativeTimeUnit(seconds: number) { const absValue = Math.abs(seconds); - let value, unit: Intl.RelativeTimeFormatUnit; - - // We have to round the resulting values, as `Intl.RelativeTimeFormat` - // will include fractions like '2.1 hours ago'. if (absValue < MINUTE) { - unit = 'second'; - value = Math.round(seconds); + return 'second'; } else if (absValue < HOUR) { - unit = 'minute'; - value = Math.round(seconds / MINUTE); + return 'minute'; } else if (absValue < DAY) { - unit = 'hour'; - value = Math.round(seconds / HOUR); + return 'hour'; } else if (absValue < WEEK) { - unit = 'day'; - value = Math.round(seconds / DAY); + return 'day'; } else if (absValue < MONTH) { - unit = 'week'; - value = Math.round(seconds / WEEK); + return 'week'; } else if (absValue < YEAR) { - unit = 'month'; - value = Math.round(seconds / MONTH); - } else { - unit = 'year'; - value = Math.round(seconds / YEAR); + return 'month'; } + return 'year'; +} - return {value, unit}; +function calculateRelativeTimeValue( + seconds: number, + unit: Intl.RelativeTimeFormatUnit +) { + // We have to round the resulting values, as `Intl.RelativeTimeFormat` + // will include fractions like '2.1 hours ago'. + return Math.round(seconds / UNIT_SECONDS[unit]); } type Props = { @@ -153,37 +170,53 @@ export default function createFormatter({ ); } + function getGlobalNow() { + if (globalNow) { + return globalNow; + } else { + onError( + new IntlError( + IntlErrorCode.ENVIRONMENT_FALLBACK, + process.env.NODE_ENV !== 'production' + ? `The \`now\` parameter wasn't provided and there is no global default configured. Consider adding a global default to avoid markup mismatches caused by environment differences. Learn more: https://next-intl-docs.vercel.app/docs/configuration#now` + : undefined + ) + ); + return new Date(); + } + } + + function extractNowDate( + nowOrOptions?: RelativeTimeFormatOptions['now'] | RelativeTimeFormatOptions + ) { + if (nowOrOptions instanceof Date || typeof nowOrOptions === 'number') { + return new Date(nowOrOptions); + } + if (nowOrOptions?.now !== undefined) { + return new Date(nowOrOptions.now); + } + return getGlobalNow(); + } + function relativeTime( /** The date time that needs to be formatted. */ date: number | Date, /** The reference point in time to which `date` will be formatted in relation to. */ - now?: number | Date + nowOrOptions?: RelativeTimeFormatOptions['now'] | RelativeTimeFormatOptions ) { try { - if (!now) { - if (globalNow) { - now = globalNow; - } else { - onError( - new IntlError( - IntlErrorCode.ENVIRONMENT_FALLBACK, - process.env.NODE_ENV !== 'production' - ? `The \`now\` parameter wasn't provided and there is no global default configured. Consider adding a global default to avoid markup mismatches caused by environment differences. Learn more: https://next-intl-docs.vercel.app/docs/configuration#now` - : undefined - ) - ); - } - } + const dateDate = new Date(date); + const nowDate = extractNowDate(nowOrOptions); + const seconds = (dateDate.getTime() - nowDate.getTime()) / 1000; - const dateDate = date instanceof Date ? date : new Date(date); - const nowDate = - now instanceof Date - ? now - : // @ts-expect-error -- `undefined` is fine for the `Date` constructor - new Date(now); + const unit = + typeof nowOrOptions === 'number' || + nowOrOptions instanceof Date || + nowOrOptions?.unit === undefined + ? resolveRelativeTimeUnit(seconds) + : nowOrOptions.unit; - const seconds = (dateDate.getTime() - nowDate.getTime()) / 1000; - const {unit, value} = getRelativeTimeFormatConfig(seconds); + const value = calculateRelativeTimeValue(seconds, unit); return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' diff --git a/packages/use-intl/test/core/createFormatter.test.tsx b/packages/use-intl/test/core/createFormatter.test.tsx index bee108652..b5ce28407 100644 --- a/packages/use-intl/test/core/createFormatter.test.tsx +++ b/packages/use-intl/test/core/createFormatter.test.tsx @@ -2,9 +2,11 @@ import {parseISO} from 'date-fns'; import {it, expect} from 'vitest'; import {createFormatter} from '../../src'; -const formatter = createFormatter({locale: 'en', timeZone: 'Europe/Berlin'}); - it('formats a date and time', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); expect( formatter.dateTime(parseISO('2020-11-20T10:36:01.516Z'), { dateStyle: 'medium' @@ -13,26 +15,75 @@ it('formats a date and time', () => { }); it('formats a number', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); expect(formatter.number(123456)).toBe('123,456'); }); it('formats a bigint', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); expect(formatter.number(123456789123456789n)).toBe('123,456,789,123,456,789'); }); it('formats a number as currency', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); expect( formatter.number(123456.789, {style: 'currency', currency: 'USD'}) ).toBe('$123,456.79'); }); it('formats a bigint as currency', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); expect( - formatter.number(123456789123456789n, {style: 'currency', currency: 'USD'}) + formatter.number(123456789123456789n, { + style: 'currency', + currency: 'USD' + }) ).toBe('$123,456,789,123,456,789.00'); }); -it('formats a relative time', () => { +it('formats a relative time with the second unit', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime( + parseISO('2020-11-20T00:00:00.000Z'), + parseISO('2020-11-20T00:00:10.000Z') + ) + ).toBe('10 seconds ago'); +}); + +it('formats a relative time with the minute unit', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime( + parseISO('2020-11-20T00:00:00.000Z'), + parseISO('2020-11-20T00:01:10.000Z') + ) + ).toBe('1 minute ago'); +}); + +it('formats a relative time with the hour unit', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); expect( formatter.relativeTime( parseISO('2020-11-20T10:36:01.516Z'), @@ -41,13 +92,112 @@ it('formats a relative time', () => { ).toBe('2 hours ago'); }); +it('formats a relative time with the day unit', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime( + parseISO('2020-11-20T00:00:00.000Z'), + parseISO('2020-11-22T00:10:00.000Z') + ) + ).toBe('2 days ago'); +}); + +it('formats a relative time with the month unit', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime( + parseISO('2022-12-01T00:00:00.000Z'), + parseISO('2023-01-01T00:00:00.000Z') + ) + ).toBe('last month'); +}); + +it('formats a relative time with the year unit', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime( + parseISO('2022-01-01T00:00:00.000Z'), + parseISO('2024-01-01T00:00:00.000Z') + ) + ).toBe('2 years ago'); +}); + +it('supports the future relative time', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime( + parseISO('2023-01-01T00:00:00.000Z'), + parseISO('2022-01-01T00:00:00.000Z') + ) + ).toBe('next year'); +}); + +it('formats a relative time with options', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime(parseISO('2020-11-20T08:30:00.000Z'), { + now: parseISO('2020-11-20T10:36:00.000Z'), + unit: 'day' + }) + ).toBe('today'); +}); + +it('supports the quarter unit', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime(parseISO('2020-01-01T00:00:00.000Z'), { + now: parseISO('2020-11-01T01:00:00.000Z'), + unit: 'quarter' + }) + ).toBe('3 quarters ago'); +}); + +it('formats a relative time with a globally defined `now`', () => { + const formatter = createFormatter({ + locale: 'en', + now: parseISO('2020-11-20T01:00:00.000Z'), + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime(parseISO('2020-11-20T00:00:00.000Z'), { + unit: 'day' + }) + ).toBe('today'); +}); + it('formats a list', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); expect( formatter.list(['apple', 'banana', 'orange'], {type: 'disjunction'}) ).toBe('apple, banana, or orange'); }); it('formats a set', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); expect( formatter.list(new Set(['apple', 'banana', 'orange']), { type: 'disjunction'