From 5adbf28aa56bdc8788265deea2c390b374bb69b9 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 2 May 2025 13:03:11 +0200 Subject: [PATCH 1/5] feat: Use `numeric: 'auto'` for relative times that don't need to be rounded --- .../src/core/createFormatter.test.tsx | 80 +++++++++++++++++-- .../use-intl/src/core/createFormatter.tsx | 49 +++++++----- 2 files changed, 101 insertions(+), 28 deletions(-) diff --git a/packages/use-intl/src/core/createFormatter.test.tsx b/packages/use-intl/src/core/createFormatter.test.tsx index 647d247dc..ba88ef843 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'], @@ -307,6 +305,25 @@ describe('relativeTime', () => { ).toBe('3 quarters ago'); }); + it('uses the `auto` representation if no rounding is needed', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin' + }); + expect( + formatter.relativeTime(parseISO('2020-11-20T00:00:00.000Z'), { + now: parseISO('2020-11-21T00:00:00.000Z'), + unit: 'day' + }) + ).toBe('yesterday'); + expect( + formatter.relativeTime(parseISO('2020-11-21T00:00:00.000Z'), { + now: parseISO('2020-11-20T00:00:00.000Z'), + unit: 'day' + }) + ).toBe('tomorrow'); + }); + it('formats a relative time with a globally defined `now`', () => { const formatter = createFormatter({ locale: 'en', @@ -319,6 +336,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) From 0209a7d5a4a43f408bba357755ec2cb216fc6d7a Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 2 May 2025 13:11:55 +0200 Subject: [PATCH 2/5] Fix test, size limit --- examples/example-app-router-playground/tests/main.spec.ts | 2 +- packages/use-intl/.size-limit.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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)", From 9d16725f53c684343fb3fca7d8fe9ec3d9b896d7 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 2 May 2025 13:31:33 +0200 Subject: [PATCH 3/5] Remove redundant test --- .../src/core/createFormatter.test.tsx | 21 +------------------ 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/packages/use-intl/src/core/createFormatter.test.tsx b/packages/use-intl/src/core/createFormatter.test.tsx index ba88ef843..59ccdc191 100644 --- a/packages/use-intl/src/core/createFormatter.test.tsx +++ b/packages/use-intl/src/core/createFormatter.test.tsx @@ -305,25 +305,6 @@ describe('relativeTime', () => { ).toBe('3 quarters ago'); }); - it('uses the `auto` representation if no rounding is needed', () => { - const formatter = createFormatter({ - locale: 'en', - timeZone: 'Europe/Berlin' - }); - expect( - formatter.relativeTime(parseISO('2020-11-20T00:00:00.000Z'), { - now: parseISO('2020-11-21T00:00:00.000Z'), - unit: 'day' - }) - ).toBe('yesterday'); - expect( - formatter.relativeTime(parseISO('2020-11-21T00:00:00.000Z'), { - now: parseISO('2020-11-20T00:00:00.000Z'), - unit: 'day' - }) - ).toBe('tomorrow'); - }); - it('formats a relative time with a globally defined `now`', () => { const formatter = createFormatter({ locale: 'en', @@ -338,7 +319,7 @@ describe('relativeTime', () => { }); describe('choosing the `auto` representation', () => { - it('uses `auto` for times <1 second', () => { + it('uses `auto` for times <=1 second', () => { const formatter = createFormatter({ locale: 'en', timeZone: 'Europe/Berlin' From 828841190481dc1aacfd801c2d6b581fd5e95c57 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 2 May 2025 16:03:15 +0200 Subject: [PATCH 4/5] docs --- docs/src/pages/docs/usage/dates-times.mdx | 31 +++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index 62f8c5fe8..3833fc7bf 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,33 @@ 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'; + +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" +const result = 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: From 982f673d8137d017b1f723c74323286ce7e3778e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 2 May 2025 16:12:22 +0200 Subject: [PATCH 5/5] Call hooks in component in docs --- docs/src/pages/docs/usage/dates-times.mdx | 28 ++++++++++++----------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index 3833fc7bf..56072d928 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -196,22 +196,24 @@ import {useFormatter, useTimeZone} from 'next-intl'; import {startOfDay} from 'date-fns'; import {tz} from '@date-fns/tz'; -const format = useFormatter(); -const timeZone = useTimeZone(); +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'); + 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)}); -} + 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" -const result = format.relativeTime(normalize(dateTime), { - now: normalize(now), - unit: 'day' -}); + // Renders "yesterday" instead of "1 day ago" + format.relativeTime(normalize(dateTime), { + now: normalize(now), + unit: 'day' + }); +} ``` ## Formatting date and time ranges [#date-time-ranges]