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: Support ISO 8601 date strings with full precision for all formatting functions where dates can be passed #758

Open
wants to merge 6 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
2 changes: 1 addition & 1 deletion packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
"size-limit": [
{
"path": "dist/production/index.react-client.js",
"limit": "15.765 KB"
"limit": "15.801 KB"
},
{
"path": "dist/production/index.react-server.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/use-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
"size-limit": [
{
"path": "dist/production/index.js",
"limit": "15.26 kB"
"limit": "15.3 kB"
}
]
}
2 changes: 1 addition & 1 deletion packages/use-intl/src/core/RelativeTimeFormatOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
type RelativeTimeFormatOptions = {
now?: number | Date;
now?: Date | number | string;
unit?: Intl.RelativeTimeFormatUnit;
numberingSystem?: string;
style?: Intl.RelativeTimeFormatStyle;
Expand Down
80 changes: 66 additions & 14 deletions packages/use-intl/src/core/createFormatter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ function calculateRelativeTimeValue(
return Math.round(seconds / UNIT_SECONDS[unit]);
}

function isDate(candidate: unknown): candidate is Date {
return candidate instanceof Date;
}

type DateInput = Date | number | string;

function isDateInput(candidate: unknown): candidate is DateInput {
return (
isDate(candidate) ||
typeof candidate === 'number' ||
typeof candidate === 'string'
);
}

type Props = {
locale: string;
timeZone?: TimeZone;
Expand Down Expand Up @@ -146,39 +160,76 @@ export default function createFormatter({
}
}

function toDate(value: DateInput): Date {
const formattable = new Date(value);

if (process.env.NODE_ENV !== 'production') {
if (
isNaN(formattable.getTime()) ||
(typeof value === 'string' &&
!value.match(
// https://stackoverflow.com/a/3143231/343045
/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/
))
) {
onError(
new IntlError(
IntlErrorCode.FORMATTING_ERROR,
typeof value === 'string'
? `Invalid ISO 8601 date string received: ${value}. Note that all parts of ISO 8601 are required: year, month, date, hour, minute, seconds, milliseconds and the timezone (e.g. '2024-02-21T07:11:36.398Z').`
: `Invalid date value received: ${value}.`
)
);
}
}

return formattable;
}

function dateTime(
/** If a number is supplied, this is interpreted as a UTC timestamp. */
value: Date | number,
/** If a number is supplied, this is interpreted as a UTC timestamp.
* If a string is supplied, this is interpreted as an ISO 8601 string. */
value: Date | number | string,
/** If a time zone is supplied, the `value` is converted to that time zone.
* Otherwise the user time zone will be used. */
formatOrOptions?: string | DateTimeFormatOptions
) {
const valueDate = toDate(value);

return getFormattedValue(
formatOrOptions,
formats?.dateTime,
(options) => {
options = applyTimeZone(options);
return new Intl.DateTimeFormat(locale, options).format(value);
return new Intl.DateTimeFormat(locale, options).format(valueDate);
},
() => String(value)
);
}

function dateTimeRange(
/** If a number is supplied, this is interpreted as a UTC timestamp. */
start: Date | number,
/** If a number is supplied, this is interpreted as a UTC timestamp. */
end: Date | number,
/** If a number is supplied, this is interpreted as a UTC timestamp.
* If a string is supplied, this is interpreted as an ISO 8601 string. */
start: Date | number | string,
/** If a number is supplied, this is interpreted as a UTC timestamp.
* If a string is supplied, this is interpreted as an ISO 8601 string. */
end: Date | number | string,
/** If a time zone is supplied, the values are converted to that time zone.
* Otherwise the user time zone will be used. */
formatOrOptions?: string | DateTimeFormatOptions
) {
const startDate = toDate(start);
const endDate = toDate(end);

return getFormattedValue(
formatOrOptions,
formats?.dateTime,
(options) => {
options = applyTimeZone(options);
return new Intl.DateTimeFormat(locale, options).formatRange(start, end);
return new Intl.DateTimeFormat(locale, options).formatRange(
startDate,
endDate
);
},
() => [dateTime(start), dateTime(end)].join(' – ')
);
Expand Down Expand Up @@ -213,20 +264,21 @@ export default function createFormatter({
}

function relativeTime(
/** The date time that needs to be formatted. */
date: number | Date,
/** If a number is supplied, this is interpreted as a UTC timestamp.
* If a string is supplied, this is interpreted as an ISO 8601 string. */
date: Date | number | string,
/** The reference point in time to which `date` will be formatted in relation to. */
nowOrOptions?: RelativeTimeFormatOptions['now'] | RelativeTimeFormatOptions
) {
try {
let nowDate: Date | undefined,
unit: Intl.RelativeTimeFormatUnit | undefined;
const opts: Intl.RelativeTimeFormatOptions = {};
if (nowOrOptions instanceof Date || typeof nowOrOptions === 'number') {
nowDate = new Date(nowOrOptions);
if (isDateInput(nowOrOptions)) {
nowDate = toDate(nowOrOptions);
} else if (nowOrOptions) {
if (nowOrOptions.now != null) {
nowDate = new Date(nowOrOptions.now);
nowDate = toDate(nowOrOptions.now);
} else {
nowDate = getGlobalNow();
}
Expand All @@ -240,7 +292,7 @@ export default function createFormatter({
nowDate = getGlobalNow();
}

const dateDate = new Date(date);
const dateDate = toDate(date);
const seconds = (dateDate.getTime() - nowDate.getTime()) / 1000;

if (!unit) {
Expand Down
110 changes: 108 additions & 2 deletions packages/use-intl/test/core/createFormatter.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {parseISO} from 'date-fns';
import {it, expect, describe} from 'vitest';
import {createFormatter} from '../../src';
import {it, expect, describe, vi} from 'vitest';
import {IntlError, IntlErrorCode, createFormatter} from '../../src';

describe('dateTime', () => {
it('formats a date and time', () => {
Expand Down Expand Up @@ -28,6 +28,36 @@ describe('dateTime', () => {
})
).toBe('Nov 20, 2020, 5:36:01 AM');
});

it('formats an ISO 8601 datetime string', () => {
const formatter = createFormatter({
locale: 'en',
timeZone: 'Europe/Berlin'
});
expect(
formatter.dateTime('2020-11-20T10:36:01.516Z', {
dateStyle: 'medium'
})
).toBe('Nov 20, 2020');
});

it('warns when an incomplete ISO 8601 datetime string is provided', () => {
const onError = vi.fn();
const formatter = createFormatter({
locale: 'en',
timeZone: 'Europe/Berlin',
onError
});
expect(formatter.dateTime('2020-11-20', {dateStyle: 'medium'})).toBe(
'Nov 20, 2020'
);
expect(onError).toHaveBeenCalledTimes(1);
const error: IntlError = onError.mock.calls[0][0];
expect(error.code).toBe(IntlErrorCode.FORMATTING_ERROR);
expect(error.message).toBe(
"FORMATTING_ERROR: Invalid ISO 8601 date string received: 2020-11-20. Note that all parts of ISO 8601 are required: year, month, date, hour, minute, seconds, milliseconds and the timezone (e.g. '2024-02-21T07:11:36.398Z')."
);
});
});

describe('number', () => {
Expand Down Expand Up @@ -280,6 +310,50 @@ describe('relativeTime', () => {
})
).toBe('in 2 days');
});

it('accepts ISO 8601 datetime strings', () => {
const formatter = createFormatter({
locale: 'en',
timeZone: 'Europe/Berlin'
});
expect(
formatter.relativeTime(
'2020-11-20T10:36:01.516Z',
'2020-11-22T11:36:01.516Z'
)
).toBe('2 days ago');
});

it('accepts an ISO 8601 datetime string for `opts.now`', () => {
const formatter = createFormatter({
locale: 'en',
timeZone: 'Europe/Berlin'
});
expect(
formatter.relativeTime('2020-11-20T10:36:01.516Z', {
now: '2020-11-22T11:36:01.516Z'
})
).toBe('2 days ago');
});

it('warns when an incomplete ISO 8601 datetime string is provided', () => {
const onError = vi.fn();
const formatter = createFormatter({
locale: 'en',
timeZone: 'Europe/Berlin',
onError
});
expect(formatter.relativeTime('2020-11-20', '2020-11-22')).toBe(
'2 days ago'
);
expect(onError).toHaveBeenCalledTimes(2);
onError.mock.calls.forEach((call) => {
expect(call[0].code).toBe(IntlErrorCode.FORMATTING_ERROR);
expect(call[0].message).toContain(
'FORMATTING_ERROR: Invalid ISO 8601 date string received'
);
});
});
});

describe('dateTimeRange', () => {
Expand Down Expand Up @@ -349,6 +423,38 @@ describe('dateTimeRange', () => {
)
).toBe('Jan 10, 2007, 4:00:00 AM – Jan 10, 2008, 5:00:00 AM');
});

it('formats ISO 8601 datetime strings', () => {
const formatter = createFormatter({
locale: 'en',
timeZone: 'Europe/Berlin'
});
expect(
formatter.dateTimeRange(
'2020-11-20T10:36:01.516Z',
'2020-11-22T11:36:01.516Z'
)
).toBe('11/20/2020 – 11/22/2020');
});

it('warns when an incomplete ISO 8601 datetime string is provided', () => {
const onError = vi.fn();
const formatter = createFormatter({
locale: 'en',
timeZone: 'Europe/Berlin',
onError
});
expect(formatter.dateTimeRange('2020-11-20', '2020-11-22')).toBe(
'11/20/2020 – 11/22/2020'
);
expect(onError).toHaveBeenCalledTimes(2);
onError.mock.calls.forEach((call) => {
expect(call[0].code).toBe(IntlErrorCode.FORMATTING_ERROR);
expect(call[0].message).toContain(
'FORMATTING_ERROR: Invalid ISO 8601 date string received'
);
});
});
});

describe('list', () => {
Expand Down
7 changes: 3 additions & 4 deletions packages/use-intl/test/react/useFormatter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,7 @@ describe('relativeTime', () => {

function Component() {
const format = useFormatter();
// @ts-expect-error Provoke an error
const date = 'not a number' as number;
const date = {} as number;
return <>{format.relativeTime(date, -20)}</>;
}

Expand All @@ -461,10 +460,10 @@ describe('relativeTime', () => {

const error: IntlError = onError.mock.calls[0][0];
expect(error.message).toBe(
'FORMATTING_ERROR: Value need to be finite number for Intl.RelativeTimeFormat.prototype.format()'
'FORMATTING_ERROR: Invalid date value received: [object Object].'
);
expect(error.code).toBe(IntlErrorCode.FORMATTING_ERROR);
expect(container.textContent).toBe('not a number');
expect(container.textContent).toBe('[object Object]');
});

it('reports an error when no `now` value is available', () => {
Expand Down
Loading