diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 6315e9268..8a1d07e9e 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -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", diff --git a/packages/use-intl/package.json b/packages/use-intl/package.json index d1ce4fc15..d299e38ca 100644 --- a/packages/use-intl/package.json +++ b/packages/use-intl/package.json @@ -90,7 +90,7 @@ "size-limit": [ { "path": "dist/production/index.js", - "limit": "15.26 kB" + "limit": "15.3 kB" } ] } diff --git a/packages/use-intl/src/core/RelativeTimeFormatOptions.tsx b/packages/use-intl/src/core/RelativeTimeFormatOptions.tsx index a40d8b436..0045c582a 100644 --- a/packages/use-intl/src/core/RelativeTimeFormatOptions.tsx +++ b/packages/use-intl/src/core/RelativeTimeFormatOptions.tsx @@ -1,5 +1,5 @@ type RelativeTimeFormatOptions = { - now?: number | Date; + now?: Date | number | string; unit?: Intl.RelativeTimeFormatUnit; numberingSystem?: string; style?: Intl.RelativeTimeFormatStyle; diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index ce15c1f14..9f106dba7 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -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; @@ -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(' – ') ); @@ -213,8 +264,9 @@ 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 ) { @@ -222,11 +274,11 @@ export default function createFormatter({ 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(); } @@ -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) { diff --git a/packages/use-intl/test/core/createFormatter.test.tsx b/packages/use-intl/test/core/createFormatter.test.tsx index 87a21a46e..7794558fa 100644 --- a/packages/use-intl/test/core/createFormatter.test.tsx +++ b/packages/use-intl/test/core/createFormatter.test.tsx @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { diff --git a/packages/use-intl/test/react/useFormatter.test.tsx b/packages/use-intl/test/react/useFormatter.test.tsx index c2a53f897..8b79c2fff 100644 --- a/packages/use-intl/test/react/useFormatter.test.tsx +++ b/packages/use-intl/test/react/useFormatter.test.tsx @@ -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)}; } @@ -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', () => {