From c97673b928de99fe8aed7573cdb3ce91e415223d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 11 Apr 2026 10:41:33 +0200 Subject: [PATCH] refactor: refactor date handling across components to use date-fns and Intl.DateTimeFormat - Replaced moment.js usage with Intl.DateTimeFormat for date formatting in various components. - Introduced utility functions `normalizeDateLocale` and `formatWithIntl` for locale normalization and date formatting. - Updated PlaylistSwitcher, RecentPlaylists, ContentCard, EPG components, and MultiEpgContainer to utilize the new date formatting approach. - Removed moment-date pipe and related tests, simplifying date handling logic. - Ensured all date displays respect the current locale settings. Entire-Checkpoint: 271bd0162027 --- apps/web/src/app/app-date-locales.ts | 46 +++++++++++++++ apps/web/src/main.ts | 3 + docs/architecture/date-handling.md | 25 ++++++++ .../playlist-switcher.component.html | 8 ++- .../playlist-switcher.component.ts | 16 ++++- .../playlist-item.component.html | 20 +++++-- .../playlist-item/playlist-item.component.ts | 16 ++++- .../content-card/content-card.component.html | 2 +- .../content-card/content-card.component.ts | 18 ++++++ .../channel-list-item.component.html | 10 +++- .../channel-list-item.component.spec.ts | 14 ++--- .../channel-list-item.component.ts | 27 +-------- .../epg-item-description.component.html | 21 ++++++- .../epg-item-description.component.ts | 41 ++++++------- .../epg-list-item.component.html | 4 +- .../epg-list-item.component.spec.ts | 55 ++++------------- .../epg-list-item/epg-list-item.component.ts | 4 +- .../src/lib/epg-list/epg-list.component.html | 3 +- .../lib/epg-list/epg-list.component.spec.ts | 59 ++++++++++--------- .../src/lib/epg-list/epg-list.component.ts | 56 +++++++++++++----- .../multi-epg-container.component.html | 12 +++- .../multi-epg-container.component.ts | 35 ++++++----- libs/ui/pipes/src/index.ts | 2 +- .../ui/pipes/src/lib/date-format.util.spec.ts | 24 ++++++++ libs/ui/pipes/src/lib/date-format.util.ts | 52 ++++++++++++++++ .../ui/pipes/src/lib/moment-date.pipe.spec.ts | 21 ------- libs/ui/pipes/src/lib/moment-date.pipe.ts | 25 -------- .../global-favorites-widget.component.ts | 19 +++--- .../recently-watched-widget.component.ts | 19 +++--- 29 files changed, 410 insertions(+), 247 deletions(-) create mode 100644 apps/web/src/app/app-date-locales.ts create mode 100644 docs/architecture/date-handling.md create mode 100644 libs/ui/pipes/src/lib/date-format.util.spec.ts create mode 100644 libs/ui/pipes/src/lib/date-format.util.ts delete mode 100644 libs/ui/pipes/src/lib/moment-date.pipe.spec.ts delete mode 100644 libs/ui/pipes/src/lib/moment-date.pipe.ts diff --git a/apps/web/src/app/app-date-locales.ts b/apps/web/src/app/app-date-locales.ts new file mode 100644 index 000000000..a9fdbb7ef --- /dev/null +++ b/apps/web/src/app/app-date-locales.ts @@ -0,0 +1,46 @@ +import { registerLocaleData } from '@angular/common'; +import localeAr from '@angular/common/locales/ar'; +import localeArMa from '@angular/common/locales/ar-MA'; +import localeBe from '@angular/common/locales/be'; +import localeDe from '@angular/common/locales/de'; +import localeEl from '@angular/common/locales/el'; +import localeEs from '@angular/common/locales/es'; +import localeFr from '@angular/common/locales/fr'; +import localeIt from '@angular/common/locales/it'; +import localeJa from '@angular/common/locales/ja'; +import localeKo from '@angular/common/locales/ko'; +import localeNl from '@angular/common/locales/nl'; +import localePl from '@angular/common/locales/pl'; +import localePt from '@angular/common/locales/pt'; +import localeRu from '@angular/common/locales/ru'; +import localeTr from '@angular/common/locales/tr'; +import localeZh from '@angular/common/locales/zh'; +import localeZhHant from '@angular/common/locales/zh-Hant'; + +let localesRegistered = false; + +export function registerAppDateLocales(): void { + if (localesRegistered) { + return; + } + + registerLocaleData(localeAr, 'ar'); + registerLocaleData(localeArMa, 'ar-MA'); + registerLocaleData(localeBe, 'be'); + registerLocaleData(localeDe, 'de'); + registerLocaleData(localeEl, 'el'); + registerLocaleData(localeEs, 'es'); + registerLocaleData(localeFr, 'fr'); + registerLocaleData(localeIt, 'it'); + registerLocaleData(localeJa, 'ja'); + registerLocaleData(localeKo, 'ko'); + registerLocaleData(localeNl, 'nl'); + registerLocaleData(localePl, 'pl'); + registerLocaleData(localePt, 'pt'); + registerLocaleData(localeRu, 'ru'); + registerLocaleData(localeTr, 'tr'); + registerLocaleData(localeZh, 'zh'); + registerLocaleData(localeZhHant, 'zh-Hant'); + + localesRegistered = true; +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index c2fda5c01..9517f00e2 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,7 +1,10 @@ import { bootstrapApplication } from '@angular/platform-browser'; +import { registerAppDateLocales } from './app/app-date-locales'; import { AppComponent } from './app/app.component'; import { appConfig } from './app/app.config'; +registerAppDateLocales(); + bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err) ); diff --git a/docs/architecture/date-handling.md b/docs/architecture/date-handling.md new file mode 100644 index 000000000..467bf4fec --- /dev/null +++ b/docs/architecture/date-handling.md @@ -0,0 +1,25 @@ +# Date Handling + +## Rules + +- Use native `Date`, ISO strings, and epoch timestamps as the stored/runtime values. +- Use `date-fns` for parsing, arithmetic, and normalization logic. +- Use Angular `DatePipe` in templates when the value is already a `Date`, ISO string, or epoch timestamp. +- Use cached `Intl.DateTimeFormat` helpers in TypeScript-only formatting paths where Angular pipes are not available. +- Do not add new `moment` usage. The app no longer depends on it. + +## Locale Strategy + +- Date display should follow the user-selected app language from `TranslateService`. +- When a template renders localized month or weekday names, pass the normalized app locale explicitly to `DatePipe`. +- Angular locale data is registered in [apps/web/src/app/app-date-locales.ts](/Users/4gray/Code/iptvnator/apps/web/src/app/app-date-locales.ts). +- App language aliases are normalized in [libs/ui/pipes/src/lib/date-format.util.ts](/Users/4gray/Code/iptvnator/libs/ui/pipes/src/lib/date-format.util.ts): + - `ary` -> `ar-MA` + - `by` -> `be` + - `zhtw` -> `zh-Hant` + +## Parsing Boundaries + +- Normalize provider-specific or legacy date strings to ISO as early as possible. +- Keep optional epoch fields such as `startTimestamp` and `stopTimestamp` when the provider already supplies them. +- Avoid new non-standard `Date.parse(...)` usage for provider formats; prefer explicit `date-fns` parsing when the input is not ISO. diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html index 3cdc63839..a50c6a083 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html @@ -125,7 +125,13 @@ {{ getPlaylistTypeLabel(playlist) }} @if (playlist.updateDate) { | - {{ playlist.updateDate | date: 'MMM d, HH:mm' }} + {{ + playlist.updateDate + | date + : 'MMM d, HH:mm' + : undefined + : currentLocale() + }} } diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 6a34e9b11..bf279a7ee 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -10,6 +10,7 @@ import { signal, viewChild, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; import { MatRippleModule } from '@angular/material/core'; @@ -17,10 +18,12 @@ import { MatDivider } from '@angular/material/divider'; import { MatIcon } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; -import { TranslatePipe } from '@ngx-translate/core'; +import { normalizeDateLocale } from '@iptvnator/pipes'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PortalStatus, PortalStatusService } from 'services'; import { PlaylistMeta } from 'shared-interfaces'; +import { startWith } from 'rxjs'; type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream'; @@ -53,7 +56,12 @@ export class PlaylistSwitcherComponent { private readonly destroyRef = inject(DestroyRef); private readonly playlistContext = inject(PlaylistContextFacade); private readonly portalStatusService = inject(PortalStatusService); + private readonly translate = inject(TranslateService); private focusSearchTimeoutId: ReturnType | null = null; + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); readonly currentTitle = input.required(); readonly subtitle = input(''); @@ -116,6 +124,12 @@ export class PlaylistSwitcherComponent { }); readonly portalStatuses = signal>(new Map()); + readonly currentLocale = computed(() => { + this.languageTick(); + return normalizeDateLocale( + this.translate.currentLang || this.translate.defaultLang + ); + }); constructor() { this.destroyRef.onDestroy(() => { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html index 409460820..b9856fc0d 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -83,11 +83,17 @@ {{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }} @if (item.importDate.length > 3) { | {{ 'HOME.PLAYLISTS.ADDED' | translate }}: - {{ item.importDate | date }} + {{ item.importDate | date: undefined : undefined : currentLocale() }} } @if (item.updateDate) { | {{ 'HOME.PLAYLISTS.UPDATED' | translate }}: - {{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }} + {{ + item.updateDate + | date + : 'MMMM d, yyyy, HH:mm' + : undefined + : currentLocale() + }} } @if (item.updateState === 2) { 3) { | {{ 'HOME.PLAYLISTS.ADDED' | translate }}: - {{ item.importDate | date }} + {{ item.importDate | date: undefined : undefined : currentLocale() }} } @if (item.updateDate) { | {{ 'HOME.PLAYLISTS.UPDATED' | translate }}: - {{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }} + {{ + item.updateDate + | date + : 'MMMM d, yyyy, HH:mm' + : undefined + : currentLocale() + }} } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts index 714744e6a..f3a59427b 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts @@ -9,12 +9,15 @@ import { input, output, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltip } from '@angular/material/tooltip'; -import { TranslatePipe } from '@ngx-translate/core'; +import { normalizeDateLocale } from '@iptvnator/pipes'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { startWith } from 'rxjs'; import { PortalStatus, PortalStatusService } from 'services'; import { PlaylistMeta } from 'shared-interfaces'; @@ -53,8 +56,19 @@ export class PlaylistItemComponent implements OnInit { portalStatus: PortalStatus = 'unavailable'; private readonly portalStatusService = inject(PortalStatusService); + private readonly translate = inject(TranslateService); + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); readonly isElectron = !!window.electron; + readonly currentLocale = computed(() => { + this.languageTick(); + return normalizeDateLocale( + this.translate.currentLang || this.translate.defaultLang + ); + }); async ngOnInit() { await this.checkPortalStatus(); diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html index b8d3fa2b2..d4d2812f2 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html @@ -41,7 +41,7 @@

{{ title() }}

@if (date()) {

- {{ date() | date: 'd MMM, HH:mm' }} + {{ date() | date: 'd MMM, HH:mm' : undefined : currentLocale() }}

} diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts index 85f4f8217..d4466e136 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts @@ -2,12 +2,18 @@ import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, + computed, + inject, input, output, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; +import { normalizeDateLocale } from '@iptvnator/pipes'; +import { TranslateService } from '@ngx-translate/core'; +import { startWith } from 'rxjs'; @Component({ selector: 'app-content-card', @@ -18,6 +24,12 @@ import { MatTooltip } from '@angular/material/tooltip'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ContentCardComponent { + private readonly translate = inject(TranslateService); + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); + /** URL of the poster image */ readonly posterUrl = input(); @@ -44,6 +56,12 @@ export class ContentCardComponent { /** Emitted when the remove button is clicked */ readonly remove = output(); + readonly currentLocale = computed(() => { + this.languageTick(); + return normalizeDateLocale( + this.translate.currentLang || this.translate.defaultLang + ); + }); /** Get the icon for the placeholder based on type */ getPlaceholderIcon(): string { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index 9e28d1b3f..403df1bf0 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -35,7 +35,10 @@
{{ - formatTime(program.start, program.startTimestamp) + (program.startTimestamp + ? program.startTimestamp * 1000 + : program.start + ) | date: 'HH:mm' }}
{{ - formatTime(program.stop, program.stopTimestamp) + (program.stopTimestamp + ? program.stopTimestamp * 1000 + : program.stop + ) | date: 'HH:mm' }}
} @else if (!isRadio()) { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts index 833acec72..32bb99dd8 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -1,3 +1,4 @@ +import { DatePipe } from '@angular/common'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialog } from '@angular/material/dialog'; @@ -58,18 +59,11 @@ describe('ChannelListItemComponent', () => { fixture.nativeElement.querySelectorAll('.epg-time'), (element: Element) => element.textContent?.trim() ?? '' ); + const datePipe = new DatePipe('en-US'); expect(times).toEqual([ - new Date(startTimestamp * 1000).toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }), - new Date(stopTimestamp * 1000).toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }), + datePipe.transform(startTimestamp * 1000, 'HH:mm') ?? '', + datePipe.transform(stopTimestamp * 1000, 'HH:mm') ?? '', ]); }); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index b30733ce3..1c7b65f5d 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -1,5 +1,5 @@ import { DragDropModule } from '@angular/cdk/drag-drop'; -import { NgStyle } from '@angular/common'; +import { DatePipe, NgStyle } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -23,6 +23,7 @@ import { EpgProgram } from 'shared-interfaces'; changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './channel-list-item.component.html', imports: [ + DatePipe, DragDropModule, MatIcon, MatIconButton, @@ -64,30 +65,6 @@ export class ChannelListItemComponent { }); } - /** - * Formats time for display (HH:mm) - */ - formatTime( - dateString: string | number, - timestampSeconds?: number | null - ): string { - const unixTimestamp = Number(timestampSeconds); - const date = - Number.isFinite(unixTimestamp) && unixTimestamp > 0 - ? new Date(unixTimestamp * 1000) - : new Date(dateString); - - if (Number.isNaN(date.getTime())) { - return ''; - } - - return date.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }); - } - /** * Opens the dialog with details about the current EPG program * @param program EPG program to show details for diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.html b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.html index d60cfc0f7..f0f924001 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.html @@ -3,11 +3,26 @@
- {{ formatTime(epgProgram.start) }} + {{ + (epgProgram.startTimestamp + ? epgProgram.startTimestamp * 1000 + : epgProgram.start + ) | date: 'HH:mm' + }} — - {{ formatTime(epgProgram.stop) }} + {{ + (epgProgram.stopTimestamp + ? epgProgram.stopTimestamp * 1000 + : epgProgram.stop + ) | date: 'HH:mm' + }}
-
{{ formatDate(epgProgram.start) }}
+
{{ + (epgProgram.startTimestamp + ? epgProgram.startTimestamp * 1000 + : epgProgram.start + ) | date: 'EEEE, MMMM d' : undefined : currentLocale() + }}
@if (channelName) {
diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts index 59bb871f1..1bdfa1ce2 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts @@ -1,8 +1,12 @@ -import { Component, inject } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { Component, computed, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { TranslatePipe } from '@ngx-translate/core'; -import { format, differenceInMinutes } from 'date-fns'; +import { normalizeDateLocale } from '@iptvnator/pipes'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { differenceInMinutes } from 'date-fns'; +import { startWith } from 'rxjs'; import { EpgProgram } from 'shared-interfaces'; type EpgItemDialogData = EpgProgram & { @@ -15,14 +19,25 @@ type EpgItemDialogData = EpgProgram & { selector: 'app-epg-item-description', templateUrl: './epg-item-description.component.html', styleUrls: ['./epg-item-description.component.scss'], - imports: [MatButtonModule, MatDialogModule, TranslatePipe], + imports: [DatePipe, MatButtonModule, MatDialogModule, TranslatePipe], }) export class EpgItemDescriptionComponent { dialogData = inject(MAT_DIALOG_DATA); + private readonly translate = inject(TranslateService); + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); epgProgram: EpgProgram; channelName: string | null = null; duration: string | null = null; + readonly currentLocale = computed(() => { + this.languageTick(); + return normalizeDateLocale( + this.translate.currentLang || this.translate.defaultLang + ); + }); constructor() { this.epgProgram = this.dialogData; @@ -34,24 +49,6 @@ export class EpgItemDescriptionComponent { this.duration = this.calculateDuration(); } - formatTime(dateStr: string): string { - if (!dateStr) return ''; - try { - return format(new Date(dateStr), 'HH:mm'); - } catch { - return ''; - } - } - - formatDate(dateStr: string): string { - if (!dateStr) return ''; - try { - return format(new Date(dateStr), 'EEEE, MMMM d'); - } catch { - return ''; - } - } - private calculateDuration(): string | null { if (!this.epgProgram.start || !this.epgProgram.stop) return null; try { diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html index a155705ca..0da51f9e1 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html @@ -1,7 +1,7 @@ {{ item.start | momentDate: 'HH:mm' }} + >{{ (item.startTimestamp ? item.startTimestamp * 1000 : item.start) | date: 'HH:mm' }} - - {{ item.stop | momentDate: 'HH:mm' }} @if (isLive) { { @@ -63,8 +30,8 @@ describe('EpgListItemComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [EpgListItemComponent, MockPipe(MomentDatePipe)], imports: [ + EpgListItemComponent, MockModule(MatDialogModule), MockModule(MatListModule), MockModule(MatIconModule), diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts index 1ec3099d5..eb83149c3 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts @@ -1,14 +1,14 @@ +import { DatePipe } from '@angular/common'; import { Component, inject, Input } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; -import { MomentDatePipe } from '@iptvnator/pipes'; import { TranslatePipe } from '@ngx-translate/core'; import { EpgProgram } from 'shared-interfaces'; import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component'; @Component({ - imports: [MomentDatePipe, MatIcon, MatTooltip, TranslatePipe], + imports: [DatePipe, MatIcon, MatTooltip, TranslatePipe], selector: 'app-epg-list-item', templateUrl: './epg-list-item.component.html', styleUrls: ['./epg-list-item.component.scss'], diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.html b/libs/ui/epg/src/lib/epg-list/epg-list.component.html index 3900d6b14..1b27b847a 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.html @@ -14,7 +14,8 @@ {{ - selectedDate() | momentDate: 'MMMM Do, dddd' : 'YYYY-MM-DD' + selectedDate() + | date: 'MMMM d, EEEE' : undefined : currentLocale() }}
- {{ today() | momentDate: 'MMMM Do, dddd' : 'YYYYMMDD' }} + {{ selectedDayDate() | date: 'MMMM d, EEEE' : undefined : currentLocale() }}
arrow_forward diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts index 283d03ad4..82a69a28e 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts @@ -1,3 +1,4 @@ +import { DatePipe } from '@angular/common'; import { OverlayRef } from '@angular/cdk/overlay'; import { @@ -13,14 +14,15 @@ import { signal, viewChild, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; -import { MomentDatePipe } from '@iptvnator/pipes'; -import { TranslatePipe } from '@ngx-translate/core'; +import { normalizeDateLocale } from '@iptvnator/pipes'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns'; -import { Observable, Subscription } from 'rxjs'; +import { Observable, Subscription, startWith } from 'rxjs'; import { Channel, EpgChannel, @@ -54,10 +56,10 @@ interface ProgramSearchResult extends EpgProgram { @Component({ imports: [ + DatePipe, MatButtonModule, MatIcon, MatTooltip, - MomentDatePipe, TranslatePipe, ], selector: 'app-multi-epg-container', @@ -104,6 +106,20 @@ export class MultiEpgContainerComponent readonly isSearchingPrograms = signal(false); readonly highlightedProgramKey = signal(null); private searchDebounceTimer: ReturnType | null = null; + private readonly translate = inject(TranslateService); + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); + readonly currentLocale = computed(() => { + this.languageTick(); + return normalizeDateLocale( + this.translate.currentLang || this.translate.defaultLang + ); + }); + readonly selectedDayDate = computed(() => + parse(this.today(), 'yyyyMMdd', new Date()) + ); // Computed signal for enriched channels - automatically updates when dependencies change readonly channels = computed(() => this.enrichProgramData()); @@ -451,17 +467,6 @@ export class MultiEpgContainerComponent return `${channelId}|${program.start}`; } - formatProgramTime(program: EpgProgram): string { - const start = new Date(program.start); - const stop = new Date(program.stop); - return `${format(start, 'HH:mm')} - ${format(stop, 'HH:mm')}`; - } - - formatProgramDate(program: EpgProgram): string { - const start = new Date(program.start); - return format(start, 'MMM d'); - } - close() { this.overlayRef.detach(); } diff --git a/libs/ui/pipes/src/index.ts b/libs/ui/pipes/src/index.ts index 6bcb0bba8..627599fa1 100644 --- a/libs/ui/pipes/src/index.ts +++ b/libs/ui/pipes/src/index.ts @@ -1,4 +1,4 @@ export * from './lib/filter.pipe'; -export * from './lib/moment-date.pipe'; +export * from './lib/date-format.util'; export * from './lib/safe.pipe'; export * from './lib/sort.pipe'; diff --git a/libs/ui/pipes/src/lib/date-format.util.spec.ts b/libs/ui/pipes/src/lib/date-format.util.spec.ts new file mode 100644 index 000000000..98fa49908 --- /dev/null +++ b/libs/ui/pipes/src/lib/date-format.util.spec.ts @@ -0,0 +1,24 @@ +import { formatWithIntl, normalizeDateLocale } from './date-format.util'; + +describe('date-format.util', () => { + it('normalizes app locale aliases to Angular/Intl locale ids', () => { + expect(normalizeDateLocale('ary')).toBe('ar-MA'); + expect(normalizeDateLocale('by')).toBe('be'); + expect(normalizeDateLocale('zhtw')).toBe('zh-Hant'); + expect(normalizeDateLocale('de')).toBe('de'); + expect(normalizeDateLocale()).toBe('en'); + }); + + it('formats dates with cached Intl formatters', () => { + expect( + formatWithIntl('2026-04-05T12:30:00.000Z', { + locale: 'de', + timeZone: 'UTC', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + ).toContain('12:30'); + }); +}); diff --git a/libs/ui/pipes/src/lib/date-format.util.ts b/libs/ui/pipes/src/lib/date-format.util.ts new file mode 100644 index 000000000..5017b72f8 --- /dev/null +++ b/libs/ui/pipes/src/lib/date-format.util.ts @@ -0,0 +1,52 @@ +const DEFAULT_DATE_LOCALE = 'en'; + +const DATE_LOCALE_ALIASES: Record = { + ary: 'ar-MA', + by: 'be', + zhtw: 'zh-Hant', +}; + +const formatterCache = new Map(); + +export function normalizeDateLocale(locale?: string | null): string { + const normalizedLocale = String(locale ?? '').trim(); + + if (!normalizedLocale) { + return DEFAULT_DATE_LOCALE; + } + + return ( + DATE_LOCALE_ALIASES[normalizedLocale.toLowerCase()] ?? normalizedLocale + ); +} + +export function formatWithIntl( + value: Date | number | string, + options: Intl.DateTimeFormatOptions & { + locale?: string | null; + timeZone?: string; + } = {} +): string { + const date = + value instanceof Date + ? value + : typeof value === 'number' + ? new Date(value) + : new Date(value); + + if (Number.isNaN(date.getTime())) { + return ''; + } + + const { locale, ...formatOptions } = options; + const resolvedLocale = normalizeDateLocale(locale); + const cacheKey = JSON.stringify([resolvedLocale, formatOptions]); + + let formatter = formatterCache.get(cacheKey); + if (!formatter) { + formatter = new Intl.DateTimeFormat(resolvedLocale, formatOptions); + formatterCache.set(cacheKey, formatter); + } + + return formatter.format(date); +} diff --git a/libs/ui/pipes/src/lib/moment-date.pipe.spec.ts b/libs/ui/pipes/src/lib/moment-date.pipe.spec.ts deleted file mode 100644 index 630ed4ae1..000000000 --- a/libs/ui/pipes/src/lib/moment-date.pipe.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { MomentDatePipe } from './moment-date.pipe'; - -describe('MomentDatePipe', () => { - let pipe: MomentDatePipe; - - beforeEach(() => { - pipe = new MomentDatePipe(); - }); - - it('formats ISO strings using the requested output format', () => { - expect(pipe.transform('2026-03-28T12:00:00.000Z', 'YYYY-MM-DD')).toBe( - '2026-03-28' - ); - }); - - it('supports explicit parse formats for non-ISO values', () => { - expect( - pipe.transform('28/03/2026', 'YYYY-MM-DD', 'DD/MM/YYYY') - ).toBe('2026-03-28'); - }); -}); diff --git a/libs/ui/pipes/src/lib/moment-date.pipe.ts b/libs/ui/pipes/src/lib/moment-date.pipe.ts deleted file mode 100644 index ec654a4e7..000000000 --- a/libs/ui/pipes/src/lib/moment-date.pipe.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import moment from 'moment'; - -/** - * Moment.js based pipe to parse and return the provided date based on given params. - * Auto-detects ISO 8601 format and converts to user's local timezone. - * Optionally accepts a format string for non-ISO date strings. - */ -@Pipe({ - name: 'momentDate', - standalone: true, -}) -export class MomentDatePipe implements PipeTransform { - transform( - value: string, - formatToReturn = 'MMMM Do, dddd', - formatToParse?: string - ): string { - // If formatToParse is provided, use it; otherwise auto-detect (works for ISO 8601) - const parsed = formatToParse - ? moment(value, formatToParse) - : moment(value); - return parsed.format(formatToReturn); - } -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts index ce7bc7420..f714f647d 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts @@ -14,6 +14,7 @@ import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { formatWithIntl } from '@iptvnator/pipes'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; import { @@ -213,16 +214,12 @@ export class GlobalFavoritesWidgetComponent implements OnInit { return this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY'); } - return date.toLocaleString( - this.translate.currentLang || - this.translate.defaultLang || - undefined, - { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - } - ); + return formatWithIntl(date, { + locale: this.translate.currentLang || this.translate.defaultLang, + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); } } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts index efbc6c383..82d35a02d 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts @@ -13,6 +13,7 @@ import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { formatWithIntl } from '@iptvnator/pipes'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; import { @@ -212,16 +213,12 @@ export class RecentlyWatchedWidgetComponent implements OnInit { return this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY'); } - return date.toLocaleString( - this.translate.currentLang || - this.translate.defaultLang || - undefined, - { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - } - ); + return formatWithIntl(date, { + locale: this.translate.currentLang || this.translate.defaultLang, + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); } }