From 988e6b87cd46d1234217981cdeaadf9787846bed Mon Sep 17 00:00:00 2001 From: Daniel Kimmich Date: Mon, 27 Feb 2023 20:49:49 +0100 Subject: [PATCH] feat: add relative time pipe --- .eslintrc.json | 1 + README.md | 27 ++- package-lock.json | 7 + package.json | 1 + .../src/lib/intl.module.ts | 3 + .../relative-time-pipe-default-options.ts | 4 + .../relative-time/relative-time.pipe.spec.ts | 213 ++++++++++++++++++ .../lib/relative-time/relative-time.pipe.ts | 82 +++++++ .../angular-ecmascript-intl/src/public-api.ts | 1 + .../src/app/pipes/pipes-routing.module.ts | 5 + .../src/app/pipes/pipes.component.html | 3 + .../src/app/pipes/pipes.module.ts | 2 + .../relative-time.component.html | 37 +++ .../relative-time.component.scss | 7 + .../relative-time/relative-time.component.ts | 16 ++ projects/angular-intl-demo/src/styles.scss | 2 +- 16 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 projects/angular-ecmascript-intl/src/lib/relative-time/relative-time-pipe-default-options.ts create mode 100644 projects/angular-ecmascript-intl/src/lib/relative-time/relative-time.pipe.spec.ts create mode 100644 projects/angular-ecmascript-intl/src/lib/relative-time/relative-time.pipe.ts create mode 100644 projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.html create mode 100644 projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.scss create mode 100644 projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.ts diff --git a/.eslintrc.json b/.eslintrc.json index ececbc52..c258a854 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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" diff --git a/README.md b/README.md index c7650e8c..b060ce7c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/package-lock.json b/package-lock.json index ff5a8002..46c56497 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,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", @@ -7281,6 +7282,12 @@ "node": ">=4.0" } }, + "node_modules/dayjs": { + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", + "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==", + "dev": true + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/package.json b/package.json index 4103487a..3f1d9197 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/projects/angular-ecmascript-intl/src/lib/intl.module.ts b/projects/angular-ecmascript-intl/src/lib/intl.module.ts index d7b77e51..237223e1 100644 --- a/projects/angular-ecmascript-intl/src/lib/intl.module.ts +++ b/projects/angular-ecmascript-intl/src/lib/intl.module.ts @@ -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: [ @@ -18,6 +19,7 @@ import {IntlListPipe} from "./list/intl-list.pipe"; IntlCountryPipe, IntlUnitPipe, IntlListPipe, + IntlRelativeTimePipe, ], exports: [ IntlDatePipe, @@ -28,6 +30,7 @@ import {IntlListPipe} from "./list/intl-list.pipe"; IntlCountryPipe, IntlUnitPipe, IntlListPipe, + IntlRelativeTimePipe, ], }) export class IntlModule { diff --git a/projects/angular-ecmascript-intl/src/lib/relative-time/relative-time-pipe-default-options.ts b/projects/angular-ecmascript-intl/src/lib/relative-time/relative-time-pipe-default-options.ts new file mode 100644 index 00000000..f38e55de --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/relative-time/relative-time-pipe-default-options.ts @@ -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>('IntlRelativeTimePipeDefaultOptions'); diff --git a/projects/angular-ecmascript-intl/src/lib/relative-time/relative-time.pipe.spec.ts b/projects/angular-ecmascript-intl/src/lib/relative-time/relative-time.pipe.spec.ts new file mode 100644 index 00000000..9582a598 --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/relative-time/relative-time.pipe.spec.ts @@ -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(); + }); + }); +}); diff --git a/projects/angular-ecmascript-intl/src/lib/relative-time/relative-time.pipe.ts b/projects/angular-ecmascript-intl/src/lib/relative-time/relative-time.pipe.ts new file mode 100644 index 00000000..aa281753 --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/relative-time/relative-time.pipe.ts @@ -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 & 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; + + constructor(@Optional() @Inject(INTL_LOCALES) readonly locales?: string | string[] | null, + @Optional() @Inject(INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS) readonly defaultOptions?: Omit | 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(); + } +} diff --git a/projects/angular-ecmascript-intl/src/public-api.ts b/projects/angular-ecmascript-intl/src/public-api.ts index 55524075..72656106 100644 --- a/projects/angular-ecmascript-intl/src/public-api.ts +++ b/projects/angular-ecmascript-intl/src/public-api.ts @@ -18,5 +18,6 @@ export * from './lib/list/intl-list-pipe-default-options'; export * from './lib/locale'; export * from './lib/percent/intl-percent.pipe'; export * from './lib/percent/intl-percent-pipe-default-options'; +export * from './lib/relative-time/relative-time.pipe'; export * from './lib/unit/intl-unit.pipe'; export * from './lib/unit/intl-unit-pipe-default-options'; diff --git a/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts b/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts index c1281904..247259fd 100644 --- a/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts +++ b/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts @@ -9,6 +9,7 @@ import {LanguageComponent} from "./language/language.component"; import {CountryComponent} from "./country/country.component"; import {UnitComponent} from "./unit/unit.component"; import {ListComponent} from "./list/list.component"; +import {RelativeTimeComponent} from "./relative-time/relative-time.component"; const routes: Routes = [ { @@ -47,6 +48,10 @@ const routes: Routes = [ path: 'list', component: ListComponent, }, + { + path: 'relative-time', + component: RelativeTimeComponent, + }, { path: '', redirectTo: 'date', diff --git a/projects/angular-intl-demo/src/app/pipes/pipes.component.html b/projects/angular-intl-demo/src/app/pipes/pipes.component.html index 86985805..ca54033c 100644 --- a/projects/angular-intl-demo/src/app/pipes/pipes.component.html +++ b/projects/angular-intl-demo/src/app/pipes/pipes.component.html @@ -15,6 +15,9 @@ routerLinkActive>Country List + Relative Time diff --git a/projects/angular-intl-demo/src/app/pipes/pipes.module.ts b/projects/angular-intl-demo/src/app/pipes/pipes.module.ts index 4d47cf45..37d7f538 100644 --- a/projects/angular-intl-demo/src/app/pipes/pipes.module.ts +++ b/projects/angular-intl-demo/src/app/pipes/pipes.module.ts @@ -17,6 +17,7 @@ import {NgxMatDatetimePickerModule, NgxMatNativeDateModule} from "@angular-mater import {MatDatepickerModule} from "@angular/material/datepicker"; import {UnitComponent} from "./unit/unit.component"; import {ListComponent} from "./list/list.component"; +import {RelativeTimeComponent} from './relative-time/relative-time.component'; @NgModule({ declarations: [ @@ -29,6 +30,7 @@ import {ListComponent} from "./list/list.component"; CountryComponent, UnitComponent, ListComponent, + RelativeTimeComponent, ], imports: [ CommonModule, diff --git a/projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.html b/projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.html new file mode 100644 index 00000000..feb7b636 --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.html @@ -0,0 +1,37 @@ +
+ + Date + + + + + + + Locale + + Browser default + {{language}} + + + + + Numeric + + Browser default + auto + always + + + + + Style + + Browser default + long + short + narrow + + +
+ +

{{selectedDate | intlRelativeTime: {locale, numeric, style} }}

diff --git a/projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.scss b/projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.scss new file mode 100644 index 00000000..d1ecbe60 --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.scss @@ -0,0 +1,7 @@ +.fields-container { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: center; + margin-bottom: 16px; +} diff --git a/projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.ts b/projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.ts new file mode 100644 index 00000000..d61887fd --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/relative-time/relative-time.component.ts @@ -0,0 +1,16 @@ +import {Component} from '@angular/core'; +import {IntlRelativeTimePipeOptions} from "projects/angular-ecmascript-intl/src/lib/relative-time/relative-time.pipe"; +import {languages} from "../../languages"; + +@Component({ + selector: 'app-relative-time', + templateUrl: './relative-time.component.html', + styleUrls: ['./relative-time.component.scss'], +}) +export class RelativeTimeComponent { + selectedDate = new Date(); + languages = languages; + numeric?: IntlRelativeTimePipeOptions['numeric']; + style?: IntlRelativeTimePipeOptions['style']; + locale?: IntlRelativeTimePipeOptions['locale']; +} diff --git a/projects/angular-intl-demo/src/styles.scss b/projects/angular-intl-demo/src/styles.scss index cc1b5fa1..7c965dd7 100644 --- a/projects/angular-intl-demo/src/styles.scss +++ b/projects/angular-intl-demo/src/styles.scss @@ -13,7 +13,7 @@ $light-theme: mat.define-light-theme(( $dark-theme: mat.define-dark-theme(( color: ( - primary: mat.define-palette(mat.$deep-purple-palette), + primary: mat.define-palette(mat.$purple-palette), accent: mat.define-palette(mat.$deep-orange-palette), ), ));