Skip to content

Commit

Permalink
Add support for formatting relative times (#286)
Browse files Browse the repository at this point in the history
  • Loading branch information
connor-baer committed Dec 5, 2024
1 parent 8c2eefd commit 4cd3fbe
Show file tree
Hide file tree
Showing 11 changed files with 479 additions and 0 deletions.
11 changes: 11 additions & 0 deletions .changeset/ninety-hornets-reply.md
Original file line number Diff line number Diff line change
@@ -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'
```
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
214 changes: 214 additions & 0 deletions src/lib/relative-time-format/index.ts
Original file line number Diff line number Diff line change
@@ -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 } };
}
37 changes: 37 additions & 0 deletions src/lib/relative-time-format/intl.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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"`;
43 changes: 43 additions & 0 deletions src/lib/relative-time-format/tests/format-to-parts.spec.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});
});
41 changes: 41 additions & 0 deletions src/lib/relative-time-format/tests/format.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading

0 comments on commit 4cd3fbe

Please sign in to comment.