Skip to content
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

feat: Add the custom unit option of relativeTime #566

Merged
merged 3 commits into from
Oct 18, 2023
Merged
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
15 changes: 15 additions & 0 deletions docs/pages/docs/usage/dates-times.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/use-intl/src/core/RelativeTimeFormatOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type RelativeTimeFormatOptions = {
now?: number | Date;
unit?: Intl.RelativeTimeFormatUnit;
};

export default RelativeTimeFormatOptions;
123 changes: 78 additions & 45 deletions packages/use-intl/src/core/createFormatter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Intl.RelativeTimeFormatUnit, number> = {
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) {
Copy link
Contributor Author

@ktmouk ktmouk Oct 15, 2023

Choose a reason for hiding this comment

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

I didn't add quarter here because I believe we don't want to use the quarter unit in most cases.

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 = {
Expand Down Expand Up @@ -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'
Expand Down
158 changes: 154 additions & 4 deletions packages/use-intl/test/core/createFormatter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'),
Expand All @@ -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'
Expand Down
Loading