Skip to content

feat: add duration pipe #139

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

Merged
merged 1 commit into from
Mar 11, 2025
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Now you can use the pipes, see below.
- [Unit pipe](#unit-pipe)
- [List pipe](#list-pipe)
- [Relative Time (timeago) pipe](#relative-time-timeago-pipe)
- [Duration pipe](#duration-pipe)

### Date pipe

Expand Down Expand Up @@ -271,6 +272,27 @@ The following options are supported:

With the `INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS` injection token you can specify default options.

## Duration pipe

Use the duration pipe like the following:

```
{{ { hours: 2, minutes: 53 } | intlDuration: options }}
```

The input can be one of the following:

- [duration object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/format#parameters)
- null
- undefined

The following options are supported:

- [`style`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat#style)
- [`fractionalDigits`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat#fractionalDigits)

For each duration unit, there is a style and display option.

## Browser compatibility

This library supports the latest major version of the following browsers:
Expand All @@ -294,6 +316,7 @@ In case you need to support older versions of that browsers, see the below table
| Unit | 77 | 78 | 14.1 |
| List | 72 | 78 | 14.1 |
| Relative Time | 71 | 65 | 14 |
| Duration | 129 | 136 | 16.4 |

## Angular compatibility table

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { InjectionToken } from '@angular/core';
import { IntlDurationPipeOptions } from './intl-duration.pipe';

export const INTL_DURATION_PIPE_DEFAULT_OPTIONS = new InjectionToken<
Omit<IntlDurationPipeOptions, 'locale'>
>('IntlDurationPipeDefaultOptions');
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { TestBed } from '@angular/core/testing';
import { INTL_LOCALES } from '../locale';
import { INTL_DURATION_PIPE_DEFAULT_OPTIONS } from './intl-duration-pipe-default-options';
import { IntlDurationPipe } from './intl-duration.pipe';

describe('IntlDurationPipe', () => {
let testUnit: IntlDurationPipe;

describe('parsing', () => {
beforeEach(() => {
TestBed.runInInjectionContext(() => {
testUnit = new IntlDurationPipe();
Object.defineProperty(testUnit, 'locale', { value: 'en-US' });
});
});

it('should create an instance', () => {
expect(testUnit).toBeTruthy();
});

it('should handle null values', () => {
expect(testUnit.transform(null)).toBeNull();
});

it('should handle undefined values', () => {
expect(testUnit.transform(undefined)).toBeNull();
});

it('should transform durations', () => {
expect(
testUnit.transform({
years: 2,
months: 11,
weeks: 2,
days: 1,
hours: 0,
minutes: 55,
seconds: 19,
milliseconds: 940,
microseconds: 10,
nanoseconds: 3,
}),
).toEqual(
'2 yrs, 11 mths, 2 wks, 1 day, 55 min, 19 sec, 940 ms, 10 μs, 3 ns',
);
});

it('should handle missing Intl.NumberFormat browser API', () => {
// @ts-expect-error Intl APIs are not expected to be undefined
spyOn(Intl, 'DurationFormat').and.returnValue(undefined);
const consoleError = spyOn(console, 'error');
expect(testUnit.transform({ years: 1 })).toBeNull();

expect(consoleError).toHaveBeenCalledTimes(1);
});
});

describe('internationalization', () => {
it('should respect the set locale', () => {
TestBed.configureTestingModule({
providers: [
{
provide: INTL_LOCALES,
useValue: 'de-DE',
},
],
});
TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe()));

expect(testUnit.transform({ years: 1 })).toEqual('1 J');
});

it('should fall back to the browser default locale', () => {
let defaultLanguageTestUnit!: IntlDurationPipe;
let browserLanguageTestUnit!: IntlDurationPipe;

TestBed.runInInjectionContext(() => {
defaultLanguageTestUnit = new IntlDurationPipe();
browserLanguageTestUnit = new IntlDurationPipe();
Object.defineProperty(browserLanguageTestUnit, 'locale', {
value: undefined,
});
Object.defineProperty(defaultLanguageTestUnit, 'locale', {
value: navigator.language,
});
});

const result1 = browserLanguageTestUnit.transform({ years: 1 });
const result2 = defaultLanguageTestUnit.transform({ years: 1 });

expect(result1).toEqual(result2);
});
});

describe('options', () => {
it('should respect the setting from default config', () => {
TestBed.configureTestingModule({
providers: [
{
provide: INTL_LOCALES,
useValue: 'en-US',
},
{
provide: INTL_DURATION_PIPE_DEFAULT_OPTIONS,
useValue: {
style: 'long',
},
},
],
});
TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe()));

expect(testUnit.transform({ years: 1 })).toEqual('1 year');
});

it('should give the user options a higher priority', () => {
TestBed.configureTestingModule({
providers: [
IntlDurationPipe,
{
provide: INTL_LOCALES,
useValue: 'en-US',
},
{
provide: INTL_DURATION_PIPE_DEFAULT_OPTIONS,
useValue: {
style: 'long',
},
},
],
});
TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe()));

expect(testUnit.transform({ years: 1 }, { style: 'narrow' })).toEqual(
'1y',
);
});
});

it('should respect locale option', () => {
TestBed.configureTestingModule({
providers: [
{
provide: INTL_LOCALES,
useValue: 'en-US',
},
],
});
TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe()));

expect(testUnit.transform({ years: 1 }, { locale: 'de-DE' })).toEqual(
'1 J',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { inject, Pipe, PipeTransform } from '@angular/core';
import { IntlPipeOptions } from '../intl-pipe-options';
import { INTL_LOCALES } from '../locale';
import { INTL_DURATION_PIPE_DEFAULT_OPTIONS } from './intl-duration-pipe-default-options';

// ToDo: remove once TypeScript includes official typings
// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace Intl {
export class DurationFormat {
constructor(locale?: string[] | string, options?: DurationFormatOptions);

format(duration: Duration): string;
}

export interface DurationFormatOptions {
style?: DurationItemStyle | 'digital';
years?: DurationItemStyle;
yearsDisplay?: DurationItemDisplay;
months?: DurationItemStyle;
monthsDisplay?: DurationItemDisplay;
weeks?: DurationItemStyle;
weeksDisplay?: DurationItemDisplay;
days?: DurationItemStyle;
daysDisplay?: DurationItemDisplay;
hours?: DurationItemStyle | 'numeric' | '2-digit';
hoursDisplay?: DurationItemDisplay;
minutes?: DurationItemStyle | 'numeric' | '2-digit';
minutesDisplay?: DurationItemDisplay;
seconds?: DurationItemStyle | 'numeric' | '2-digit';
secondsDisplay?: DurationItemDisplay;
milliseconds?: DurationItemStyle | 'numeric' | '2-digit';
millisecondsDisplay?: DurationItemDisplay;
microseconds?: DurationItemStyle | 'numeric';
microsecondsDisplay?: DurationItemDisplay;
nanoseconds?: DurationItemStyle | 'numeric';
nanosecondsDisplay?: DurationItemDisplay;
fractionalDigits?: number;
}

export type DurationItemStyle = 'long' | 'short' | 'narrow';
export type DurationItemDisplay = 'always' | 'auto';

export interface Duration {
years?: number;
months?: number;
weeks?: number;
days?: number;
hours?: number;
minutes?: number;
seconds?: number;
milliseconds?: number;
microseconds?: number;
nanoseconds?: number;
}
}

export type IntlDurationPipeOptions = Partial<Intl.DurationFormatOptions> &
IntlPipeOptions;

@Pipe({
name: 'intlDuration',
standalone: true,
})
export class IntlDurationPipe implements PipeTransform {
readonly locale = inject(INTL_LOCALES, { optional: true });
readonly defaultOptions = inject(INTL_DURATION_PIPE_DEFAULT_OPTIONS, {
optional: true,
});

transform(
value: Intl.Duration | null | undefined,
options?: IntlDurationPipeOptions,
): string | null {
if (!value) {
return null;
}

const { locale, ...intlOptions } = options ?? {};

try {
return new Intl.DurationFormat(locale ?? this.locale ?? undefined, {
...this.defaultOptions,
...intlOptions,
}).format(value);
} catch (e) {
console.error('Error while transforming the duration value', e);
return null;
}
}
}
3 changes: 3 additions & 0 deletions projects/angular-ecmascript-intl/src/lib/intl.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IntlCountryPipe } from './country/intl-country.pipe';
import { IntlCurrencyPipe } from './currency/intl-currency.pipe';
import { IntlDatePipe } from './date/intl-date.pipe';
import { IntlDecimalPipe } from './decimal/intl-decimal.pipe';
import { IntlDurationPipe } from './duration/intl-duration.pipe';
import { IntlLanguagePipe } from './language/intl-language.pipe';
import { IntlListPipe } from './list/intl-list.pipe';
import { IntlPercentPipe } from './percent/intl-percent.pipe';
Expand All @@ -20,6 +21,7 @@ import { IntlUnitPipe } from './unit/intl-unit.pipe';
IntlUnitPipe,
IntlListPipe,
IntlRelativeTimePipe,
IntlDurationPipe,
],
exports: [
IntlDatePipe,
Expand All @@ -31,6 +33,7 @@ import { IntlUnitPipe } from './unit/intl-unit.pipe';
IntlUnitPipe,
IntlListPipe,
IntlRelativeTimePipe,
IntlDurationPipe,
],
})
export class IntlModule {}
2 changes: 2 additions & 0 deletions projects/angular-ecmascript-intl/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export * from './lib/date/intl-date-pipe-default-options';
export * from './lib/date/intl-date.pipe';
export * from './lib/decimal/intl-decimal-pipe-default-options';
export * from './lib/decimal/intl-decimal.pipe';
export * from './lib/duration/intl-duration-pipe-default-options';
export * from './lib/duration/intl-duration.pipe';
export * from './lib/intl.module';
export * from './lib/language/intl-language-pipe-default-options';
export * from './lib/language/intl-language.pipe';
Expand Down
Loading