Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 6d56906

Browse files
authoredFeb 27, 2023
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+
}

‎projects/angular-ecmascript-intl/src/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ export * from './lib/list/intl-list-pipe-default-options';
1818
export * from './lib/locale';
1919
export * from './lib/percent/intl-percent.pipe';
2020
export * from './lib/percent/intl-percent-pipe-default-options';
21+
export * from './lib/relative-time/relative-time.pipe';
2122
export * from './lib/unit/intl-unit.pipe';
2223
export * from './lib/unit/intl-unit-pipe-default-options';

‎projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {LanguageComponent} from "./language/language.component";
99
import {CountryComponent} from "./country/country.component";
1010
import {UnitComponent} from "./unit/unit.component";
1111
import {ListComponent} from "./list/list.component";
12+
import {RelativeTimeComponent} from "./relative-time/relative-time.component";
1213

1314
const routes: Routes = [
1415
{
@@ -47,6 +48,10 @@ const routes: Routes = [
4748
path: 'list',
4849
component: ListComponent,
4950
},
51+
{
52+
path: 'relative-time',
53+
component: RelativeTimeComponent,
54+
},
5055
{
5156
path: '',
5257
redirectTo: 'date',

‎projects/angular-intl-demo/src/app/pipes/pipes.component.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
routerLinkActive>Country</a>
1616
<a #listActive="routerLinkActive" [active]="listActive.isActive" mat-tab-link routerLink="list"
1717
routerLinkActive>List</a>
18+
<a #relativeTimeActive="routerLinkActive" [active]="relativeTimeActive.isActive" mat-tab-link
19+
routerLink="relative-time"
20+
routerLinkActive>Relative Time</a>
1821
</nav>
1922
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
2023

‎projects/angular-intl-demo/src/app/pipes/pipes.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {NgxMatDatetimePickerModule, NgxMatNativeDateModule} from "@angular-mater
1717
import {MatDatepickerModule} from "@angular/material/datepicker";
1818
import {UnitComponent} from "./unit/unit.component";
1919
import {ListComponent} from "./list/list.component";
20+
import {RelativeTimeComponent} from './relative-time/relative-time.component';
2021

2122
@NgModule({
2223
declarations: [
@@ -29,6 +30,7 @@ import {ListComponent} from "./list/list.component";
2930
CountryComponent,
3031
UnitComponent,
3132
ListComponent,
33+
RelativeTimeComponent,
3234
],
3335
imports: [
3436
CommonModule,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<div class="fields-container">
2+
<mat-form-field>
3+
<mat-label>Date</mat-label>
4+
<input [(ngModel)]="selectedDate" [ngxMatDatetimePicker]="picker" matInput placeholder="Choose a date">
5+
<mat-datepicker-toggle [for]="$any(picker)" matSuffix></mat-datepicker-toggle>
6+
<ngx-mat-datetime-picker #picker></ngx-mat-datetime-picker>
7+
</mat-form-field>
8+
9+
<mat-form-field>
10+
<mat-label>Locale</mat-label>
11+
<mat-select [(ngModel)]="locale">
12+
<mat-option [value]="undefined">Browser default</mat-option>
13+
<mat-option *ngFor="let language of languages" [value]="language">{{language}}</mat-option>
14+
</mat-select>
15+
</mat-form-field>
16+
17+
<mat-form-field>
18+
<mat-label>Numeric</mat-label>
19+
<mat-select [(ngModel)]="numeric">
20+
<mat-option [value]="undefined">Browser default</mat-option>
21+
<mat-option [value]="'auto'">auto</mat-option>
22+
<mat-option [value]="'always'">always</mat-option>
23+
</mat-select>
24+
</mat-form-field>
25+
26+
<mat-form-field>
27+
<mat-label>Style</mat-label>
28+
<mat-select [(ngModel)]="style">
29+
<mat-option [value]="undefined">Browser default</mat-option>
30+
<mat-option [value]="'long'">long</mat-option>
31+
<mat-option [value]="'short'">short</mat-option>
32+
<mat-option [value]="'narrow'">narrow</mat-option>
33+
</mat-select>
34+
</mat-form-field>
35+
</div>
36+
37+
<p>{{selectedDate | intlRelativeTime: {locale, numeric, style} }}</p>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.fields-container {
2+
display: flex;
3+
gap: 16px;
4+
flex-wrap: wrap;
5+
align-items: center;
6+
margin-bottom: 16px;
7+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {Component} from '@angular/core';
2+
import {IntlRelativeTimePipeOptions} from "projects/angular-ecmascript-intl/src/lib/relative-time/relative-time.pipe";
3+
import {languages} from "../../languages";
4+
5+
@Component({
6+
selector: 'app-relative-time',
7+
templateUrl: './relative-time.component.html',
8+
styleUrls: ['./relative-time.component.scss'],
9+
})
10+
export class RelativeTimeComponent {
11+
selectedDate = new Date();
12+
languages = languages;
13+
numeric?: IntlRelativeTimePipeOptions['numeric'];
14+
style?: IntlRelativeTimePipeOptions['style'];
15+
locale?: IntlRelativeTimePipeOptions['locale'];
16+
}

‎projects/angular-intl-demo/src/styles.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ $light-theme: mat.define-light-theme((
1313

1414
$dark-theme: mat.define-dark-theme((
1515
color: (
16-
primary: mat.define-palette(mat.$deep-purple-palette),
16+
primary: mat.define-palette(mat.$purple-palette),
1717
accent: mat.define-palette(mat.$deep-orange-palette),
1818
),
1919
));

0 commit comments

Comments
 (0)
Please sign in to comment.