Skip to content

feat: Use numeric: 'auto' for relative times that don't need to be rounded for a given unit #1872

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions docs/src/pages/docs/usage/dates-times.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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'
});
}
```
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Been scratching my head a bit over this code snippet. I think it should now be correct, but it's a bit verbose.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically what I had to do anyways with my current approach (with the exception of using Luxon instead of date-fns) to ensure that numeric: auto gave the proper localized text for "yesterday", "today", etc.

Copy link

@kvnxiao kvnxiao May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if there's a way to simplify this, unless we allow passing in an optional config param like shouldNormalizeDate: boolean to the format.relativeTime() options - but that gets into the issue of requiring next-intl to supply its own "start of day" function calculation if it wants to be date-library agnostic.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question is also what to normalize to. The start of the day is an example here, but you might normalize to a week (e.g. "last week"). I don't think we can make an assumption here.


## Formatting date and time ranges [#date-time-ranges]

You can format ranges of dates and times with the `dateTimeRange` function:
Expand Down
2 changes: 1 addition & 1 deletion examples/example-app-router-playground/tests/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/use-intl/.size-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
61 changes: 54 additions & 7 deletions packages/use-intl/src/core/createFormatter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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'],
Expand All @@ -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'],
Expand Down Expand Up @@ -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', () => {
Expand Down
49 changes: 28 additions & 21 deletions packages/use-intl/src/core/createFormatter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this always override to numeric: auto whenever possible? I'm all for supporting numeric: auto but not sure if it's a good idea for us to always try to coerce into "auto" and at least provide the user the flexibility to opt out as a custom configuration they can pass in

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of having this as the default, but we should give the flexibility to allow the user to opt out if necessary. I think that might be better, rather than trying to expend effort to figure out a justification of why we shouldn't do it

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also unsure what the best way really is here.

Some aspects to consider:

Differences to Intl.RelativeTimeFormat

format.relativeTime is not just "use Intl.RelativeTime with the current locale".

Instead, it's a function to produce readable relative time output and internally does:

  1. Calculate a time difference
  2. Detect an appropriate unit (can be overridden)
  3. Round to the nearest value based on the unit
  4. Apply the locale to a cached formatter

So I think some deviations from Intl.RelativeTime might be fine. Generally, I'd like the function to produce a readable output for the consumer with the least amount of config and edge cases to consider.

Using numeric: 'auto' is quite tricky

It requires date calculations on the consumer side, also taking into account the current time zone. If the user would be able to simply pass numeric: 'auto', they might see a result like "yesterday" and will push to production.

It could break in other cases though, due to the rounding that next-intl does. We could add a big disclaimer in the docs, but users might miss this if they discover the option in their IDE via IntelliSense.

Generally, it's quite a lot of effort to support cases like "yesterday" (see the code snippet in the docs).

Which special cases does CLDR provide?

CLDR seems to mostly has special cases for -1, 0, 1 for auto.

Example from en.xml:

<relative type="-1">yesterday</relative>
<relative type="0">today</relative>
<relative type="1">tomorrow</relative>

Maybe it would help to create a script that extracts all currently known cases to get a better idea what auto can produce.

My main question is: Are there cases where auto would produce an output that is not desired? That's the point you've raised above, and I'm also wondering if that might be the case.

auto doesn't work for months, quarters and years

Due to an approximation that next-intl has to make, it's practically impossible for users to trigger auto special cases for this.

If this is the output you need, you might be better off using Intl.RelativeTime directly.

Format.js allows passing auto

FormattedRelativeTime allows to set numeric: 'auto'.

It's interesting to see, but I really wonder how many times users run into issues like incorrectly rounded values. They don't have any validation against rounded values, so they make it really easy to trigger cases like "yesterday", but this will break in many cases. I'm not sure if this is a reasonable solution that I'd like to provide.


So there are still some open questions here for me and I'm not really confident what the best solution is.

Another option would be to provide a separate function that mimics Intl.RelativeTime closely and only applies the locale (and also benefits from internal caching).

Example:

format.relativeTimeValue(1, 'day', {numeric: 'auto'});

This would put more work into the hand of a user, but gives you all the flexibility. It's almost the same as using Intl.RelativeTimeFormat yourself though (which is what you're doing currently)—not sure if it's worth adding and maintaining this.

So these are my current thoughts. I created this PR to explore the topic in more depth, but I didn't really come to a place yet where my questions are cleared up unfortunately …

What's your position?

}

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)
Expand Down