Skip to content

Commit 6d56906

Browse files
authored
feat: add relative time pipe (#14)
1 parent 1008584 commit 6d56906

16 files changed

+404
-7
lines changed

.eslintrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@typescript-eslint/no-extraneous-class": "off",
2121
"no-unused-vars": "off",
2222
"@typescript-eslint/no-unused-vars": "error",
23+
"@typescript-eslint/prefer-literal-enum-member": "off",
2324
"comma-dangle": [
2425
"error",
2526
"always-multiline"

README.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,27 @@ their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/G
213213

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

216-
## Background
216+
## Relative Time (timeago) pipe
217217

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

220-
## Feature Roadmap
220+
```
221+
{{myDate | intlRelativeTime: options}}
222+
```
221223

222-
* Performance: Prepare Intl.* object with default options, only construct new object when necessary
223-
* Relative time pipe
224-
* Migration Schematics for usages of Angular pipes
224+
The input date can be one of the following:
225+
226+
* `Date` object
227+
* number (UNIX timestamp)
228+
* string (will be parsed by `new Date()` constructor)
229+
* null
230+
* undefined
231+
232+
The options are a subset of the options for `new Intl.RelativeTimeFormat()`. For a list of the options, see
233+
their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#options).
234+
235+
With the `INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS` injection token you can specify default options.
236+
237+
## Background
238+
239+
For more context, see the following [GitHub issue](https://github.com/angular/angular/issues/49143)

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@typescript-eslint/eslint-plugin": "~5.53.0",
4747
"@typescript-eslint/parser": "~5.53.0",
4848
"cpy-cli": "^4.2.0",
49+
"dayjs": "^1.11.7",
4950
"eslint": "^8.33.0",
5051
"jasmine-core": "~4.5.0",
5152
"karma": "~6.4.0",

projects/angular-ecmascript-intl/src/lib/intl.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {IntlCurrencyPipe} from "./currency/intl-currency.pipe";
77
import {IntlCountryPipe} from "./country/intl-country.pipe";
88
import {IntlUnitPipe} from "./unit/intl-unit.pipe";
99
import {IntlListPipe} from "./list/intl-list.pipe";
10+
import {IntlRelativeTimePipe} from "./relative-time/relative-time.pipe";
1011

1112
@NgModule({
1213
imports: [
@@ -18,6 +19,7 @@ import {IntlListPipe} from "./list/intl-list.pipe";
1819
IntlCountryPipe,
1920
IntlUnitPipe,
2021
IntlListPipe,
22+
IntlRelativeTimePipe,
2123
],
2224
exports: [
2325
IntlDatePipe,
@@ -28,6 +30,7 @@ import {IntlListPipe} from "./list/intl-list.pipe";
2830
IntlCountryPipe,
2931
IntlUnitPipe,
3032
IntlListPipe,
33+
IntlRelativeTimePipe,
3134
],
3235
})
3336
export class IntlModule {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import {InjectionToken} from "@angular/core";
2+
import {IntlRelativeTimePipeOptions} from "./relative-time.pipe";
3+
4+
export const INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS = new InjectionToken<Omit<IntlRelativeTimePipeOptions, 'locale'>>('IntlRelativeTimePipeDefaultOptions');
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import {IntlRelativeTimePipe} from './relative-time.pipe';
2+
import * as dayjs from 'dayjs'
3+
import {fakeAsync, TestBed, tick} from "@angular/core/testing";
4+
import {INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS} from "./relative-time-pipe-default-options";
5+
import {INTL_LOCALES} from "../locale";
6+
import {ChangeDetectorRef} from "@angular/core";
7+
8+
describe('RelativeTimePipe', () => {
9+
let testUnit: IntlRelativeTimePipe;
10+
11+
it('should create an instance', () => {
12+
testUnit = new IntlRelativeTimePipe();
13+
expect(testUnit).toBeTruthy();
14+
});
15+
16+
describe('parsing', () => {
17+
beforeEach(() => {
18+
testUnit = new IntlRelativeTimePipe('en-US');
19+
});
20+
21+
it('should handle null values', () => {
22+
expect(testUnit.transform(null)).toEqual(null);
23+
});
24+
25+
it('should handle undefined values', () => {
26+
expect(testUnit.transform(undefined)).toEqual(null);
27+
});
28+
29+
it('should handle empty strings', () => {
30+
expect(testUnit.transform('')).toEqual(null);
31+
});
32+
33+
it('should throw an error when an invalid string is passed', () => {
34+
expect(() => testUnit.transform('someInvalidDate')).toThrowError('someInvalidDate is not a valid date');
35+
});
36+
37+
it('should throw an error when an invalid date is passed', () => {
38+
expect(() => testUnit.transform(new Date('invalid'))).toThrowError('Invalid Date is not a valid date');
39+
});
40+
41+
it('should support string value', () => {
42+
expect(testUnit.transform(new Date().toISOString())).toEqual('in 0 minutes');
43+
});
44+
45+
it('should support number value', () => {
46+
expect(testUnit.transform(new Date().getTime())).toEqual('in 0 minutes');
47+
});
48+
49+
it('should support Date value', () => {
50+
expect(testUnit.transform(new Date())).toEqual('in 0 minutes');
51+
});
52+
53+
describe('years', () => {
54+
it('should transform a date one year in past', () => {
55+
const date = dayjs().subtract(1, 'year').subtract(1, 'second').toDate();
56+
57+
expect(testUnit.transform(date)).toEqual('1 year ago');
58+
});
59+
60+
it('should transform a date almost 3 years in future', () => {
61+
const date = dayjs().add(365 * 3, 'days').subtract(1, 'second').toDate();
62+
63+
expect(testUnit.transform(date)).toEqual('in 2 years');
64+
});
65+
});
66+
67+
describe('months', () => {
68+
it('should transform a date 1 month in future', () => {
69+
const date = dayjs().add(31, 'days').add(1, 'second').toDate();
70+
71+
expect(testUnit.transform(date)).toEqual('in 1 month');
72+
});
73+
74+
it('should transform a date almost 12 months in past', () => {
75+
const date = dayjs().subtract(30 * 12, 'days').add(1, 'second').toDate();
76+
77+
expect(testUnit.transform(date)).toEqual('11 months ago');
78+
});
79+
});
80+
81+
describe('weeks', () => {
82+
it('should transform a date 1 week in future', () => {
83+
const date = dayjs().add(1, 'week').add(1, 'second').toDate();
84+
85+
expect(testUnit.transform(date)).toEqual('in 1 week');
86+
});
87+
88+
it('should transform a date almost 4 weeks in past', () => {
89+
const date = dayjs().subtract(4, 'weeks').add(1, 'second').toDate();
90+
91+
expect(testUnit.transform(date)).toEqual('3 weeks ago');
92+
});
93+
});
94+
95+
describe('days', () => {
96+
it('should transform a date 1 day in future', () => {
97+
const date = dayjs().add(1, 'day').add(1, 'second').toDate();
98+
99+
expect(testUnit.transform(date)).toEqual('in 1 day');
100+
});
101+
102+
it('should transform a date almost 7 days in past', () => {
103+
const date = dayjs().subtract(7, 'days').add(1, 'second').toDate();
104+
105+
expect(testUnit.transform(date)).toEqual('6 days ago');
106+
});
107+
});
108+
109+
describe('hours', () => {
110+
it('should transform a date 1 hour in future', () => {
111+
const date = dayjs().add(1, 'hour').add(1, 'second').toDate();
112+
113+
expect(testUnit.transform(date)).toEqual('in 1 hour');
114+
});
115+
116+
it('should transform a date almost 24 hours in past', () => {
117+
const date = dayjs().subtract(24, 'hours').add(1, 'second').toDate();
118+
119+
expect(testUnit.transform(date)).toEqual('23 hours ago');
120+
});
121+
});
122+
123+
describe('minutes', () => {
124+
it('should transform a date 1 minute in future', () => {
125+
const date = dayjs().add(1, 'minute').add(1, 'second').toDate();
126+
127+
expect(testUnit.transform(date)).toEqual('in 1 minute');
128+
});
129+
130+
it('should transform a date almost 59 minutes in past', () => {
131+
const date = dayjs().subtract(60, 'minutes').add(1, 'second').toDate();
132+
133+
expect(testUnit.transform(date)).toEqual('59 minutes ago');
134+
});
135+
});
136+
137+
it('should transform a date almost than 1 minute in past', () => {
138+
const date = dayjs().subtract(1, 'minute').add(1, 'second').toDate();
139+
140+
expect(testUnit.transform(date)).toEqual('in 0 minutes');
141+
});
142+
});
143+
144+
describe('options', () => {
145+
beforeEach(() => {
146+
TestBed.configureTestingModule({
147+
providers: [
148+
IntlRelativeTimePipe,
149+
{
150+
provide: INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS,
151+
useValue: {numeric: 'auto', style: 'short'},
152+
},
153+
{
154+
provide: INTL_LOCALES,
155+
useValue: 'en-US',
156+
},
157+
],
158+
});
159+
testUnit = TestBed.inject(IntlRelativeTimePipe);
160+
});
161+
162+
it('should respect the default options', () => {
163+
expect(testUnit.transform(new Date())).toEqual('this minute')
164+
});
165+
166+
it('should give the passed options a higher priority', () => {
167+
expect(testUnit.transform(new Date(), {numeric: 'always'})).toEqual('in 0 min.');
168+
});
169+
170+
it('should apply the locale from the passed options', () => {
171+
expect(testUnit.transform(new Date(), {locale: 'de-DE'})).toEqual('in dieser Minute');
172+
});
173+
});
174+
175+
it('should fall back to the default locale', () => {
176+
TestBed.configureTestingModule({providers: [IntlRelativeTimePipe]});
177+
178+
const result1 = TestBed.inject(IntlRelativeTimePipe).transform(new Date());
179+
const result2 = new IntlRelativeTimePipe(navigator.language).transform(new Date());
180+
181+
expect(result1).toEqual(result2);
182+
});
183+
184+
describe('timer', () => {
185+
const cdrMock = {markForCheck: jasmine.createSpy()} as unknown as ChangeDetectorRef;
186+
187+
beforeEach(() => {
188+
testUnit = new IntlRelativeTimePipe(null, null, cdrMock)
189+
});
190+
191+
it('should mark for check once after 1 minute', fakeAsync(() => {
192+
testUnit.transform(0);
193+
tick(60000);
194+
195+
expect(cdrMock.markForCheck).toHaveBeenCalledTimes(1);
196+
197+
testUnit.ngOnDestroy();
198+
}));
199+
200+
it('should mark for check 10 times after 10 minutes', fakeAsync(() => {
201+
testUnit.transform(new Date());
202+
tick(600000);
203+
204+
expect(cdrMock.markForCheck).toHaveBeenCalledTimes(10);
205+
206+
testUnit.ngOnDestroy();
207+
}));
208+
209+
afterEach(() => {
210+
(cdrMock.markForCheck as jasmine.Spy).calls.reset();
211+
});
212+
});
213+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {ChangeDetectorRef, Inject, OnDestroy, Optional, Pipe, PipeTransform} from '@angular/core';
2+
import {INTL_LOCALES} from "../locale";
3+
import {IntlPipeOptions} from "../intl-pipe-options";
4+
import {INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS} from "./relative-time-pipe-default-options";
5+
import {interval, Subject, takeUntil} from "rxjs";
6+
7+
export type IntlRelativeTimePipeOptions = Partial<Intl.RelativeTimeFormatOptions> & IntlPipeOptions;
8+
9+
enum Time {
10+
oneSecond = 1000,
11+
oneMinute = Time.oneSecond * 60,
12+
oneHour = Time.oneMinute * 60,
13+
oneDay = Time.oneHour * 24,
14+
oneWeek = Time.oneDay * 7,
15+
oneMonth = Time.oneDay * 30,
16+
oneYear = Time.oneDay * 365,
17+
}
18+
19+
@Pipe({
20+
name: 'intlRelativeTime',
21+
standalone: true,
22+
pure: false,
23+
})
24+
export class IntlRelativeTimePipe implements PipeTransform, OnDestroy {
25+
26+
#destroy$?: Subject<void>;
27+
28+
constructor(@Optional() @Inject(INTL_LOCALES) readonly locales?: string | string[] | null,
29+
@Optional() @Inject(INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS) readonly defaultOptions?: Omit<IntlRelativeTimePipeOptions, 'locale'> | null,
30+
@Optional() readonly cdr?: ChangeDetectorRef) {
31+
}
32+
33+
transform(value: string | number | Date | null | undefined, options?: IntlRelativeTimePipeOptions): string | null {
34+
if (typeof value !== 'number' && !value) {
35+
return null;
36+
}
37+
38+
const time = new Date(value).getTime();
39+
if (isNaN(time)) {
40+
throw new Error(`${value} is not a valid date`);
41+
}
42+
43+
this.#destroy();
44+
this.#destroy$ = new Subject();
45+
interval(Time.oneMinute)
46+
.pipe(takeUntil(this.#destroy$))
47+
.subscribe(() => this.cdr?.markForCheck());
48+
49+
const relativeTimeFormat = new Intl.RelativeTimeFormat(
50+
options?.locale ?? this.locales ?? undefined,
51+
{...this.defaultOptions, ...options},
52+
);
53+
54+
const currentTime = new Date().getTime();
55+
const factor = time < currentTime ? -1 : 1;
56+
const diff = Math.abs(time - currentTime);
57+
if (diff > Time.oneYear) {
58+
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneYear), 'year');
59+
} else if (diff > Time.oneMonth) {
60+
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneMonth), 'month');
61+
} else if (diff > Time.oneWeek) {
62+
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneWeek), 'week');
63+
} else if (diff > Time.oneDay) {
64+
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneDay), 'day');
65+
} else if (diff > Time.oneHour) {
66+
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneHour), 'hour');
67+
} else if (diff > Time.oneMinute) {
68+
return relativeTimeFormat.format(factor * Math.floor(diff / Time.oneMinute), 'minute');
69+
} else {
70+
return relativeTimeFormat.format(0, 'minute');
71+
}
72+
}
73+
74+
ngOnDestroy(): void {
75+
this.#destroy();
76+
}
77+
78+
#destroy(): void {
79+
this.#destroy$?.next();
80+
this.#destroy$?.complete();
81+
}
82+
}

0 commit comments

Comments
 (0)