diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index 62f8c5fe8..56072d928 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -168,9 +168,9 @@ function Component() { } ``` -### Customizing the unit [#relative-times-unit] +### `unit` [#relative-times-unit] -By default, `relativeTime` will pick a unit based on the difference between the passed date and `now` like "3 seconds" or "5 days". +By default, `relativeTime` will pick an appropriate unit based on the difference between the passed date and `now` like "3 seconds", "5 days" or "1 year". If you want to use a specific unit, you can provide options via the second argument: @@ -187,6 +187,35 @@ function Component() { } ``` +Furthermore, `relativeTime` will automatically use [`numeric: 'auto'`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#numeric) when the time difference divided by the unit is a whole number without a fractional part (e.g. exactly one day). This enables natural phrases like "yesterday" instead of "1 day ago" when appropriate. + +You can use utility functions like [`startOfDay`](https://date-fns.org/docs/startOfDay) to ensure that the time difference is a whole number: + +```js +import {useFormatter, useTimeZone} from 'next-intl'; +import {startOfDay} from 'date-fns'; +import {tz} from '@date-fns/tz'; + +function Component() { + const format = useFormatter(); + const timeZone = useTimeZone(); + + const now = new Date('2020-12-23T10:36:00.000Z'); + const dateTime = new Date('2020-12-22T08:23:00.000Z'); + + function normalize(date) { + // The "start of a day" depends on a time zone + return startOfDay(date, {in: tz(timeZone)}); + } + + // Renders "yesterday" instead of "1 day ago" + format.relativeTime(normalize(dateTime), { + now: normalize(now), + unit: 'day' + }); +} +``` + ## Formatting date and time ranges [#date-time-ranges] You can format ranges of dates and times with the `dateTimeRange` function: diff --git a/examples/example-app-router-playground/tests/main.spec.ts b/examples/example-app-router-playground/tests/main.spec.ts index 61d522dd3..c6c41005f 100644 --- a/examples/example-app-router-playground/tests/main.spec.ts +++ b/examples/example-app-router-playground/tests/main.spec.ts @@ -237,7 +237,7 @@ it('can use `getMessageFallback`', async ({page}) => { it('can use the core library', async ({page}) => { await page.goto('/en'); const element = page.getByTestId('CoreLibrary'); - await expect(element).toHaveText('Relative time: in 1 day'); + await expect(element).toHaveText('Relative time: tomorrow'); }); it('can use `Link` on the server', async ({page}) => { diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index e40ea80af..58dc4a293 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,7 +5,7 @@ const config: SizeLimitConfig = [ name: "import * from 'use-intl' (production)", import: '*', path: 'dist/esm/production/index.js', - limit: '13.015 kB' + limit: '12.975 kB' }, { name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", diff --git a/packages/use-intl/src/core/createFormatter.test.tsx b/packages/use-intl/src/core/createFormatter.test.tsx index 647d247dc..59ccdc191 100644 --- a/packages/use-intl/src/core/createFormatter.test.tsx +++ b/packages/use-intl/src/core/createFormatter.test.tsx @@ -201,23 +201,21 @@ describe('relativeTime', () => { it.each([ ['2022-07-10T15:00:00.000Z', '2 years ago'], ['2022-07-11T15:00:00.000Z', '1 year ago'], - ['2023-01-09T15:00:00.000Z', '1 year ago'], + ['2023-01-08T15:00:00.000Z', '1 year ago'], ['2023-01-10T15:00:00.000Z', '12 months ago'], ['2023-07-09T15:00:00.000Z', '6 months ago'], ['2023-12-09T15:00:00.000Z', '1 month ago'], ['2023-12-10T15:00:00.000Z', '4 weeks ago'], - ['2024-01-02T15:00:00.000Z', '1 week ago'], + ['2024-01-01T15:00:00.000Z', '1 week ago'], ['2024-01-03T15:00:00.000Z', '6 days ago'], - ['2024-01-08T15:00:00.000Z', '1 day ago'], + ['2024-01-08T14:00:00.000Z', '1 day ago'], ['2024-01-08T15:01:00.000Z', '24 hours ago'], ['2024-01-09T14:00:00.000Z', '1 hour ago'], ['2024-01-09T14:01:00.000Z', '59 minutes ago'], ['2024-01-09T14:59:00.000Z', '1 minute ago'], ['2024-01-09T14:59:01.000Z', '59 seconds ago'], ['2024-01-09T14:59:59.000Z', '1 second ago'], - ['2024-01-09T14:59:59.999Z', 'now'], - ['2024-01-09T15:00:00.001Z', 'now'], ['2024-01-09T15:00:01.000Z', 'in 1 second'], ['2024-01-09T15:00:59.000Z', 'in 59 seconds'], ['2024-01-09T15:01:00.000Z', 'in 1 minute'], @@ -226,7 +224,7 @@ describe('relativeTime', () => { ['2024-01-09T23:59:00.000Z', 'in 9 hours'], ['2024-01-10T00:00:00.000Z', 'in 9 hours'], ['2024-01-10T14:59:00.000Z', 'in 24 hours'], - ['2024-01-10T15:00:00.000Z', 'in 1 day'], + ['2024-01-10T16:00:00.000Z', 'in 1 day'], ['2024-01-10T23:59:00.000Z', 'in 1 day'], ['2024-01-11T00:00:00.000Z', 'in 1 day'], ['2024-01-11T01:00:00.000Z', 'in 1 day'], @@ -237,7 +235,7 @@ describe('relativeTime', () => { ['2024-02-06T00:00:00.000Z', 'in 4 weeks'], ['2024-02-06T15:00:00.000Z', 'in 4 weeks'], ['2024-02-09T00:00:00.000Z', 'in 4 weeks'], - ['2024-02-09T01:00:00.000Z', 'in 1 month'], + ['2024-02-10T01:00:00.000Z', 'in 1 month'], ['2024-04-09T00:00:00.000Z', 'in 3 months'], ['2024-12-09T00:00:00.000Z', 'in 11 months'], ['2024-12-31T00:00:00.000Z', 'in 12 months'], @@ -319,6 +317,55 @@ describe('relativeTime', () => { }) ).toBe('in 2 days'); }); + + describe('choosing the `auto` representation', () => { + it('uses `auto` for times <=1 second', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + const now = parseISO('2020-11-20T00:00:00.000Z'); + expect( + formatter.relativeTime(parseISO('2020-11-20T00:00:00.200Z'), { + now + }) + ).toBe('now'); + expect( + formatter.relativeTime(parseISO('2020-11-19T23:59:59.900Z'), { + now + }) + ).toBe('now'); + }); + + it('can accept an explicit `unit`', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime(parseISO('2020-11-20T00:00:00.000Z'), { + now: parseISO('2020-11-20T00:00:00.000Z'), + unit: 'day' + }) + ).toBe('today'); + }); + + it.each([ + ['last week', parseISO('2020-11-13T00:00:00.000Z')], + ['yesterday', parseISO('2020-11-19T00:00:00.000Z')], + ['tomorrow', parseISO('2020-11-21T00:00:00.000Z')] + ])('formats %s correctly', (expected, date) => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime(date, { + now: parseISO('2020-11-20T00:00:00.000Z') + }) + ).toBe(expected); + }); + }); }); describe('dateTimeRange', () => { diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index a14ecaa55..3084ec374 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -62,15 +62,6 @@ function resolveRelativeTimeUnit(seconds: number) { return 'year'; } -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 = { locale: Locale; timeZone?: TimeZone; @@ -309,24 +300,40 @@ export default function createFormatter(props: Props) { } const dateDate = new Date(date); - const seconds = (dateDate.getTime() - nowDate.getTime()) / 1000; + + // Rounding is fine here because `Intl.RelativeTimeFormat` + // doesn't support units smaller than seconds. + const seconds = Math.round( + (dateDate.getTime() - nowDate.getTime()) / 1000 + ); if (!unit) { unit = resolveRelativeTimeUnit(seconds); } - // `numeric: 'auto'` can theoretically produce output like "yesterday", - // but it only works with integers. E.g. -1 day will produce "yesterday", - // but -1.1 days will produce "-1.1 days". Rounding before formatting is - // not desired, as the given dates might cross a threshold were the - // output isn't correct anymore. Example: 2024-01-08T23:00:00.000Z and - // 2024-01-08T01:00:00.000Z would produce "yesterday", which is not the - // case. By using `always` we can ensure correct output. The only exception - // is the formatting of times <1 second as "now". - opts.numeric = unit === 'second' ? 'auto' : 'always'; + // We have to round the resulting values, as `Intl.RelativeTimeFormat` + // would include fractions like '2.1 hours ago'. + const unitValue = seconds / UNIT_SECONDS[unit]; + const rounded = Math.round(unitValue); + + // `numeric: 'auto'` works well for formatting values that don't + // have a fractional part (e.g. "yesterday") + // + // However, it should not be used with rounded values, as the given + // dates might cross a threshold were the output isn't correct anymore. + // Example: 2024-01-08T23:00:00.000Z and 2024-01-08T01:00:00.000Z would + // produce "yesterday", which is not the case. By using `always` in this + // case, we can ensure correct output. + // + // Note that due to approximations being used for months and years, it's + // practically impossible to trigger the cases "last month" or "last year". + if (unitValue === rounded) { + opts.numeric = 'auto'; + } - const value = calculateRelativeTimeValue(seconds, unit); - return formatters.getRelativeTimeFormat(locale, opts).format(value, unit); + return formatters + .getRelativeTimeFormat(locale, opts) + .format(rounded, unit); } catch (error) { onError( new IntlError(IntlErrorCode.FORMATTING_ERROR, (error as Error).message)