Skip to content

feat: add relative time pipe #14

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 2 commits into from
Feb 27, 2023
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
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@typescript-eslint/no-extraneous-class": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/prefer-literal-enum-member": "off",
"comma-dangle": [
"error",
"always-multiline"
Expand Down
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,27 @@ their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/G

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

## Background
## Relative Time (timeago) pipe

For more context, see the following [GitHub issue](https://github.com/angular/angular/issues/49143)
Use the relative time pipe like the following:

## Feature Roadmap
```
{{myDate | intlRelativeTime: options}}
```

* Performance: Prepare Intl.* object with default options, only construct new object when necessary
* Relative time pipe
* Migration Schematics for usages of Angular pipes
The input date can be one of the following:

* `Date` object
* number (UNIX timestamp)
* string (will be parsed by `new Date()` constructor)
* null
* undefined

The options are a subset of the options for `new Intl.RelativeTimeFormat()`. For a list of the options, see
their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#options).

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

## Background

For more context, see the following [GitHub issue](https://github.com/angular/angular/issues/49143)
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@typescript-eslint/eslint-plugin": "~5.53.0",
"@typescript-eslint/parser": "~5.53.0",
"cpy-cli": "^4.2.0",
"dayjs": "^1.11.7",
"eslint": "^8.33.0",
"jasmine-core": "~4.5.0",
"karma": "~6.4.0",
Expand Down
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 @@ -7,6 +7,7 @@ import {IntlCurrencyPipe} from "./currency/intl-currency.pipe";
import {IntlCountryPipe} from "./country/intl-country.pipe";
import {IntlUnitPipe} from "./unit/intl-unit.pipe";
import {IntlListPipe} from "./list/intl-list.pipe";
import {IntlRelativeTimePipe} from "./relative-time/relative-time.pipe";

@NgModule({
imports: [
Expand All @@ -18,6 +19,7 @@ import {IntlListPipe} from "./list/intl-list.pipe";
IntlCountryPipe,
IntlUnitPipe,
IntlListPipe,
IntlRelativeTimePipe,
],
exports: [
IntlDatePipe,
Expand All @@ -28,6 +30,7 @@ import {IntlListPipe} from "./list/intl-list.pipe";
IntlCountryPipe,
IntlUnitPipe,
IntlListPipe,
IntlRelativeTimePipe,
],
})
export class IntlModule {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {InjectionToken} from "@angular/core";
import {IntlRelativeTimePipeOptions} from "./relative-time.pipe";

export const INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS = new InjectionToken<Omit<IntlRelativeTimePipeOptions, 'locale'>>('IntlRelativeTimePipeDefaultOptions');
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import {IntlRelativeTimePipe} from './relative-time.pipe';
import * as dayjs from 'dayjs'
import {fakeAsync, TestBed, tick} from "@angular/core/testing";
import {INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS} from "./relative-time-pipe-default-options";
import {INTL_LOCALES} from "../locale";
import {ChangeDetectorRef} from "@angular/core";

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

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

describe('parsing', () => {
beforeEach(() => {
testUnit = new IntlRelativeTimePipe('en-US');
});

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

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

it('should handle empty strings', () => {
expect(testUnit.transform('')).toEqual(null);
});

it('should throw an error when an invalid string is passed', () => {
expect(() => testUnit.transform('someInvalidDate')).toThrowError('someInvalidDate is not a valid date');
});

it('should throw an error when an invalid date is passed', () => {
expect(() => testUnit.transform(new Date('invalid'))).toThrowError('Invalid Date is not a valid date');
});

it('should support string value', () => {
expect(testUnit.transform(new Date().toISOString())).toEqual('in 0 minutes');
});

it('should support number value', () => {
expect(testUnit.transform(new Date().getTime())).toEqual('in 0 minutes');
});

it('should support Date value', () => {
expect(testUnit.transform(new Date())).toEqual('in 0 minutes');
});

describe('years', () => {
it('should transform a date one year in past', () => {
const date = dayjs().subtract(1, 'year').subtract(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('1 year ago');
});

it('should transform a date almost 3 years in future', () => {
const date = dayjs().add(365 * 3, 'days').subtract(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('in 2 years');
});
});

describe('months', () => {
it('should transform a date 1 month in future', () => {
const date = dayjs().add(31, 'days').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('in 1 month');
});

it('should transform a date almost 12 months in past', () => {
const date = dayjs().subtract(30 * 12, 'days').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('11 months ago');
});
});

describe('weeks', () => {
it('should transform a date 1 week in future', () => {
const date = dayjs().add(1, 'week').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('in 1 week');
});

it('should transform a date almost 4 weeks in past', () => {
const date = dayjs().subtract(4, 'weeks').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('3 weeks ago');
});
});

describe('days', () => {
it('should transform a date 1 day in future', () => {
const date = dayjs().add(1, 'day').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('in 1 day');
});

it('should transform a date almost 7 days in past', () => {
const date = dayjs().subtract(7, 'days').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('6 days ago');
});
});

describe('hours', () => {
it('should transform a date 1 hour in future', () => {
const date = dayjs().add(1, 'hour').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('in 1 hour');
});

it('should transform a date almost 24 hours in past', () => {
const date = dayjs().subtract(24, 'hours').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('23 hours ago');
});
});

describe('minutes', () => {
it('should transform a date 1 minute in future', () => {
const date = dayjs().add(1, 'minute').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('in 1 minute');
});

it('should transform a date almost 59 minutes in past', () => {
const date = dayjs().subtract(60, 'minutes').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('59 minutes ago');
});
});

it('should transform a date almost than 1 minute in past', () => {
const date = dayjs().subtract(1, 'minute').add(1, 'second').toDate();

expect(testUnit.transform(date)).toEqual('in 0 minutes');
});
});

describe('options', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
IntlRelativeTimePipe,
{
provide: INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS,
useValue: {numeric: 'auto', style: 'short'},
},
{
provide: INTL_LOCALES,
useValue: 'en-US',
},
],
});
testUnit = TestBed.inject(IntlRelativeTimePipe);
});

it('should respect the default options', () => {
expect(testUnit.transform(new Date())).toEqual('this minute')
});

it('should give the passed options a higher priority', () => {
expect(testUnit.transform(new Date(), {numeric: 'always'})).toEqual('in 0 min.');
});

it('should apply the locale from the passed options', () => {
expect(testUnit.transform(new Date(), {locale: 'de-DE'})).toEqual('in dieser Minute');
});
});

it('should fall back to the default locale', () => {
TestBed.configureTestingModule({providers: [IntlRelativeTimePipe]});

const result1 = TestBed.inject(IntlRelativeTimePipe).transform(new Date());
const result2 = new IntlRelativeTimePipe(navigator.language).transform(new Date());

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

describe('timer', () => {
const cdrMock = {markForCheck: jasmine.createSpy()} as unknown as ChangeDetectorRef;

beforeEach(() => {
testUnit = new IntlRelativeTimePipe(null, null, cdrMock)
});

it('should mark for check once after 1 minute', fakeAsync(() => {
testUnit.transform(0);
tick(60000);

expect(cdrMock.markForCheck).toHaveBeenCalledTimes(1);

testUnit.ngOnDestroy();
}));

it('should mark for check 10 times after 10 minutes', fakeAsync(() => {
testUnit.transform(new Date());
tick(600000);

expect(cdrMock.markForCheck).toHaveBeenCalledTimes(10);

testUnit.ngOnDestroy();
}));

afterEach(() => {
(cdrMock.markForCheck as jasmine.Spy).calls.reset();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {ChangeDetectorRef, Inject, OnDestroy, Optional, Pipe, PipeTransform} from '@angular/core';
import {INTL_LOCALES} from "../locale";
import {IntlPipeOptions} from "../intl-pipe-options";
import {INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS} from "./relative-time-pipe-default-options";
import {interval, Subject, takeUntil} from "rxjs";

export type IntlRelativeTimePipeOptions = Partial<Intl.RelativeTimeFormatOptions> & IntlPipeOptions;

enum Time {
oneSecond = 1000,
oneMinute = Time.oneSecond * 60,
oneHour = Time.oneMinute * 60,
oneDay = Time.oneHour * 24,
oneWeek = Time.oneDay * 7,
oneMonth = Time.oneDay * 30,
oneYear = Time.oneDay * 365,
}

@Pipe({
name: 'intlRelativeTime',
standalone: true,
pure: false,
})
export class IntlRelativeTimePipe implements PipeTransform, OnDestroy {

#destroy$?: Subject<void>;

constructor(@Optional() @Inject(INTL_LOCALES) readonly locales?: string | string[] | null,
@Optional() @Inject(INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS) readonly defaultOptions?: Omit<IntlRelativeTimePipeOptions, 'locale'> | null,
@Optional() readonly cdr?: ChangeDetectorRef) {
}

transform(value: string | number | Date | null | undefined, options?: IntlRelativeTimePipeOptions): string | null {
if (typeof value !== 'number' && !value) {
return null;
}

const time = new Date(value).getTime();
if (isNaN(time)) {
throw new Error(`${value} is not a valid date`);
}

this.#destroy();
this.#destroy$ = new Subject();
interval(Time.oneMinute)
.pipe(takeUntil(this.#destroy$))
.subscribe(() => this.cdr?.markForCheck());

const relativeTimeFormat = new Intl.RelativeTimeFormat(
options?.locale ?? this.locales ?? undefined,
{...this.defaultOptions, ...options},
);

const currentTime = new Date().getTime();
const factor = time < currentTime ? -1 : 1;
const diff = Math.abs(time - currentTime);
if (diff > Time.oneYear) {
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneYear), 'year');
} else if (diff > Time.oneMonth) {
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneMonth), 'month');
} else if (diff > Time.oneWeek) {
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneWeek), 'week');
} else if (diff > Time.oneDay) {
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneDay), 'day');
} else if (diff > Time.oneHour) {
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneHour), 'hour');
} else if (diff > Time.oneMinute) {
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneMinute), 'minute');
} else {
return relativeTimeFormat.format(0, 'minute');
}
}

ngOnDestroy(): void {
this.#destroy();
}

#destroy(): void {
this.#destroy$?.next();
this.#destroy$?.complete();
}
}
Loading