diff --git a/.changeset/ninety-hornets-reply.md b/.changeset/ninety-hornets-reply.md new file mode 100644 index 0000000..510c9b2 --- /dev/null +++ b/.changeset/ninety-hornets-reply.md @@ -0,0 +1,11 @@ +--- +'@sumup-oss/intl': minor +--- + +Added support for formatting relative times. + +```ts +import { formatRelativeTime } from '@sumup-oss/intl'; + +formatRelativeTime(7, 'years', 'pt-BR'); // 'em 7 anos' +``` diff --git a/src/index.ts b/src/index.ts index 34e883d..6716442 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,4 +32,10 @@ export { isDateTimeFormatSupported, isDateTimeFormatToPartsSupported, } from './lib/date-time-format/index.js'; +export { + formatRelativeTime, + formatRelativeTimeToParts, + resolveRelativeTimeFormat, + isRelativeTimeFormatSupported, +} from './lib/relative-time-format/index.js'; export { CURRENCIES } from './data/currencies.js'; diff --git a/src/lib/relative-time-format/index.ts b/src/lib/relative-time-format/index.ts new file mode 100644 index 0000000..2da1bc9 --- /dev/null +++ b/src/lib/relative-time-format/index.ts @@ -0,0 +1,214 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Locale } from '../../types/index.js'; +import { formatNumber, formatNumberToParts } from '../number-format/index.js'; + +import { + getRelativeTimeFormat, + isRelativeTimeFormatSupported, +} from './intl.js'; + +export { isRelativeTimeFormatSupported }; + +/** + * Formats a relative time with support for various [styles](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#style). + * + * @example + * import { formatRelativeTime } from '@sumup-oss/intl'; + * + * formatRelativeTime(1, 'day', 'de-DE'); // 'in 1 Tag' + * formatRelativeTime(7, 'years', ['pt-BR', 'pt']); // 'em 7 anos' + * formatRelativeTime(-5, 'months', 'en-GB', { + * style: 'narrow', + * }); // '5 mo ago' + * + * @remarks + * In runtimes that don't support the `Intl.RelativeTimeFormat` API, + * the relative time is formatted using the `Intl.NumberFormat` API instead. + * + * @category Date & Time + */ +export const formatRelativeTime = formatRelativeTimeFactory(); + +function formatRelativeTimeFactory(): ( + value: number, + unit: Intl.RelativeTimeFormatUnit, + locales?: Locale | Locale[], + options?: Intl.RelativeTimeFormatOptions, +) => string { + if (!isRelativeTimeFormatSupported) { + return (value, unit, locales, options) => { + const numberFormat = convertToNumberFormat(value, unit, options); + return formatNumber(numberFormat.value, locales, numberFormat.options); + }; + } + + return (value, unit, locales, options) => { + const relativeTimeFormat = getRelativeTimeFormat(locales, options); + return relativeTimeFormat.format(value, unit); + }; +} + +/** + * Formats a relative time to parts with support for various [styles](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#style). + * + * @example + * import { formatRelativeTimeToParts } from '@sumup-oss/intl'; + * + * formatRelativeTimeToParts(1, 'day', 'de-DE'); + * // [ + * // { "type": "literal", "value": "in " }, + * // { "type": "integer", "unit": "day", "value": "1" }, + * // { "type": "literal", "value": " Tag" } + * // ] + * formatRelativeTimeToParts(7, 'years', ['pt-BR', 'pt']); + * // [ + * // { "type": "literal", "value": "em " }, + * // { "type": "integer", "unit": "year", "value": "7" }, + * // { "type": "literal", "value": " anos" } + * // ] + * formatRelativeTimeToParts(-5, 'months', 'en-GB', { + * style: 'narrow', + * }); + * // [ + * // { "type": "integer", "unit": "month", "value": "5" }, + * // { "type": "literal", "value": " mo ago" } + * // ] + * + * @remarks + * In runtimes that don't support the `Intl.RelativeTimeFormat` API, + * the relative time is formatted using the `Intl.NumberFormat` API instead. + * + * @category Date & Time + */ +export const formatRelativeTimeToParts = formatRelativeTimeToPartsFactory(); + +function formatRelativeTimeToPartsFactory(): ( + value: number, + unit: Intl.RelativeTimeFormatUnit, + locales?: Locale | Locale[], + options?: Intl.RelativeTimeFormatOptions, +) => Intl.RelativeTimeFormatPart[] { + if (!isRelativeTimeFormatSupported) { + return (value, unit, locales, options) => { + // In runtimes that don't support formatting to parts, the relative time + // is formatted using the `Intl.NumberFormat` API instead. + const numberFormat = convertToNumberFormat(value, unit, options); + const numberFormatParts = formatNumberToParts( + numberFormat.value, + locales, + numberFormat.options, + ); + + // Smooth over the subtle differences between NumberFormatPart and RelativeTimeFormatPart + return numberFormatParts.map((part) => { + if (part.type === 'integer') { + return { ...part, unit: numberFormat.options.unit }; + } + if (part.type === 'unit') { + return { ...part, type: 'literal' }; + } + return part; + }) as Intl.RelativeTimeFormatPart[]; + }; + } + + return (value, unit, locales, options) => { + const relativeTimeFormat = getRelativeTimeFormat(locales, options); + return relativeTimeFormat.formatToParts(value, unit); + }; +} + +/** + * Resolves the locale and collation options that are used to format a relative time. + * + * @example + * import { resolveRelativeTimeFormat } from '@sumup-oss/intl'; + * + * resolveRelativeTimeFormat('de-DE'); + * // { + * // "locale": "de-DE", + * // "numberingSystem": "latn", + * // "numeric": "always", + * // "style": "long", + * // } + * resolveRelativeTimeFormat(['pt-BR', 'pt']); + * // { + * // "locale": "pt-BR", + * // "numberingSystem": "latn", + * // "numeric": "always", + * // "style": "long", + * // } + * resolveRelativeTimeFormat('en-GB', { + * style: 'narrow', + * }); + * // { + * // "locale": "en-GB", + * // "numberingSystem": "latn", + * // "numeric": "always", + * // "style": "narrow", + * // } + * + * @remarks + * In runtimes that don't support the `Intl.RelativeTimeFormat.resolvedOptions` API, + * `null` is returned. + * + * @category Date & Time + */ +export const resolveRelativeTimeFormat = resolveRelativeTimeFormatFactory(); + +function resolveRelativeTimeFormatFactory(): ( + locales?: Locale | Locale[], + options?: Intl.RelativeTimeFormatOptions, +) => null | Intl.ResolvedRelativeTimeFormatOptions { + if (!isRelativeTimeFormatSupported) { + return () => null; + } + + return (locales, options) => { + const relativeTimeFormat = getRelativeTimeFormat(locales, options); + return relativeTimeFormat.resolvedOptions(); + }; +} + +function convertToNumberFormat( + value: number, + unit: Intl.RelativeTimeFormatUnit, + options: Intl.RelativeTimeFormatOptions | undefined, +): { value: number; options: Intl.NumberFormatOptions } { + const style = 'unit'; + const unitDisplay = options?.style || 'long'; + + // Intl.NumberFormat doesn't support the 'quarter' unit + if (unit.startsWith('quarter')) { + return { value: value * 3, options: { unit: 'month', style, unitDisplay } }; + } + + const numberFormatUnits = [ + 'year', + 'month', + 'week', + 'day', + 'hour', + 'minute', + 'second', + ]; + // Intl.NumberFormat only supports singular unit names + const singularUnit = numberFormatUnits.find((supportedUnit) => + unit.startsWith(supportedUnit), + ); + return { value, options: { unit: singularUnit, style, unitDisplay } }; +} diff --git a/src/lib/relative-time-format/intl.ts b/src/lib/relative-time-format/intl.ts new file mode 100644 index 0000000..f9ed474 --- /dev/null +++ b/src/lib/relative-time-format/intl.ts @@ -0,0 +1,37 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Locale } from '../../types/index.js'; +import { memoize } from '../memoize.js'; + +/** + * Whether the `Intl` and `Intl.RelativeTimeFormat` APIs + * are supported by the runtime. + */ +export const isRelativeTimeFormatSupported = (() => { + try { + return ( + typeof Intl !== 'undefined' && + typeof Intl.RelativeTimeFormat !== 'undefined' + ); + } catch (error) { + return false; + } +})(); + +export const getRelativeTimeFormat = memoize(Intl.RelativeTimeFormat) as ( + locales?: Locale | Locale[], + options?: Intl.RelativeTimeFormatOptions, +) => Intl.RelativeTimeFormat; diff --git a/src/lib/relative-time-format/tests/__snapshots__/unsupported-intl-api.spec.ts.snap b/src/lib/relative-time-format/tests/__snapshots__/unsupported-intl-api.spec.ts.snap new file mode 100644 index 0000000..b45272d --- /dev/null +++ b/src/lib/relative-time-format/tests/__snapshots__/unsupported-intl-api.spec.ts.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Relative times > when Intl.RelativeTimeFormat is unsupported > should format -5 months as a relative time 1`] = `"-5 months"`; + +exports[`Relative times > when Intl.RelativeTimeFormat is unsupported > should format 1 day as a relative time 1`] = `"1 day"`; + +exports[`Relative times > when Intl.RelativeTimeFormat is unsupported > should format 3 quarters as a relative time 1`] = `"9 months"`; + +exports[`Relative times > when Intl.RelativeTimeFormat is unsupported > should format 7 years as a relative time 1`] = `"7 years"`; diff --git a/src/lib/relative-time-format/tests/format-to-parts.spec.ts b/src/lib/relative-time-format/tests/format-to-parts.spec.ts new file mode 100644 index 0000000..165e196 --- /dev/null +++ b/src/lib/relative-time-format/tests/format-to-parts.spec.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2020, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import type { SpyImpl } from 'tinyspy'; + +import { formatRelativeTimeToParts } from '../index.js'; + +import { relativeTimes, locales } from './shared.js'; + +const spy = Intl.RelativeTimeFormat as unknown as SpyImpl; + +describe('Relative times', () => { + describe('formatRelativeTimeToParts', () => { + it.each(locales)('should format a date for %o', (locale) => { + const index = locales.indexOf(locale); + const [value, unit] = relativeTimes[0]; + const actual = formatRelativeTimeToParts(value, unit, locale); + expect(actual).toBeArray(); + expect(spy.calls[index]).toEqual([locale, undefined]); + }); + + it.each(relativeTimes)('should format %s %s', (value, unit) => { + const locale = locales[0]; + const actual = formatRelativeTimeToParts(value, unit, locale); + expect(actual).toBeArray(); + const valuePart = actual.find((part) => part.type === 'integer'); + expect(valuePart?.value).toBe(Math.abs(value).toString()); + }); + }); +}); diff --git a/src/lib/relative-time-format/tests/format.spec.ts b/src/lib/relative-time-format/tests/format.spec.ts new file mode 100644 index 0000000..cbd3272 --- /dev/null +++ b/src/lib/relative-time-format/tests/format.spec.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2020, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import type { SpyImpl } from 'tinyspy'; + +import { formatRelativeTime } from '../index.js'; + +import { locales, relativeTimes } from './shared.js'; + +const spy = Intl.RelativeTimeFormat as unknown as SpyImpl; + +describe('Relative times', () => { + describe('formatRelativeTime', () => { + it.each(locales)('should format a relative time for %o', (locale) => { + const index = locales.indexOf(locale); + const [value, unit] = relativeTimes[0]; + const actual = formatRelativeTime(value, unit, locale); + expect(actual).toBeString(); + expect(spy.calls[index]).toEqual([locale, undefined]); + }); + + it.each(relativeTimes)('should format %s %s', (value, unit, expected) => { + const locale = locales[0]; + const actual = formatRelativeTime(value, unit, locale); + expect(actual).toBe(expected); + }); + }); +}); diff --git a/src/lib/relative-time-format/tests/resolve-format.spec.ts b/src/lib/relative-time-format/tests/resolve-format.spec.ts new file mode 100644 index 0000000..7ff2732 --- /dev/null +++ b/src/lib/relative-time-format/tests/resolve-format.spec.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2020, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; + +import { resolveRelativeTimeFormat } from '../index.js'; + +import { locales } from './shared.js'; + +describe('Relative times', () => { + describe('resolveRelativeTimeFormat', () => { + it.each(locales)('should get the relative time format for %o', (locale) => { + const actual = resolveRelativeTimeFormat(locale); + expect(actual).toBeObject(); + }); + }); +}); diff --git a/src/lib/relative-time-format/tests/shared.ts b/src/lib/relative-time-format/tests/shared.ts new file mode 100644 index 0000000..6243287 --- /dev/null +++ b/src/lib/relative-time-format/tests/shared.ts @@ -0,0 +1,28 @@ +/** + * Copyright 2022, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const locales: (string | string[])[] = [ + 'de-DE', + 'es-US', + ['DE', 'US'], + ['de-DE', 'es-US'], +]; + +export const relativeTimes: [number, Intl.RelativeTimeFormatUnit, string][] = [ + [1, 'day', 'in 1 Tag'], + [-5, 'months', 'vor 5 Monaten'], + [3, 'quarters', 'in 3 Quartalen'], + [7, 'years', 'in 7 Jahren'], +]; diff --git a/src/lib/relative-time-format/tests/unsupported-intl-api.spec.ts b/src/lib/relative-time-format/tests/unsupported-intl-api.spec.ts new file mode 100644 index 0000000..6a5c51f --- /dev/null +++ b/src/lib/relative-time-format/tests/unsupported-intl-api.spec.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2022, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { vi, describe, it, expect } from 'vitest'; + +import { + formatRelativeTime, + formatRelativeTimeToParts, + resolveRelativeTimeFormat, +} from '../index.js'; + +import { relativeTimes } from './shared.js'; + +vi.mock('../intl', async () => { + const intl = await vi.importActual('../intl'); + return { + ...intl, + isRelativeTimeFormatSupported: false, + }; +}); + +const locale = 'xx-XX'; + +describe('Relative times', () => { + describe('when Intl.RelativeTimeFormat is unsupported', () => { + it.each(relativeTimes)( + 'should format %s %s as a relative time', + (value, unit) => { + const actual = formatRelativeTime(value, unit, locale); + expect(actual).toMatchSnapshot(); + }, + ); + + it('should format a relative time to numeric parts', () => { + const [value, unit] = relativeTimes[0]; + const parts = formatRelativeTimeToParts(value, unit, locale); + expect(parts).toHaveLength(10); + }); + + it('should return `null` for the relative time format', () => { + const actual = resolveRelativeTimeFormat(locale); + expect(actual).toBeNull(); + }); + }); +}); diff --git a/vitest.setup.js b/vitest.setup.js index a3f5586..0e4c85d 100644 --- a/vitest.setup.js +++ b/vitest.setup.js @@ -1,9 +1,13 @@ import { vi, expect } from 'vitest'; +import { spyOn } from 'tinyspy'; import * as matchers from 'jest-extended'; import { Intl as IntlWithTemporal } from 'temporal-polyfill'; expect.extend(matchers); +// Intl.RelativeTimeFormat can't be spied upon (https://github.com/vitest-dev/vitest/issues/6104) +spyOn(Intl, 'RelativeTimeFormat'); + vi.spyOn(Intl, 'NumberFormat'); vi.spyOn(IntlWithTemporal, 'DateTimeFormat');