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
This commit is contained in:
4gray committed 2026-04-11 10:41:33 +02:00
1 parent d3e30056f6
commit c97673b928
29 files changed
+410 -247

No files matched your search

+46
View File
@@ -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;
}
+3
View File
@@ -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)
);
+25
View File
@@ -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.
@@ -125,7 +125,13 @@
{{ getPlaylistTypeLabel(playlist) }}
@if (playlist.updateDate) {
<span class="separator">|</span>
{{ playlist.updateDate | date: 'MMM d, HH:mm' }}
{{
playlist.updateDate
| date
: 'MMM d, HH:mm'
: undefined
: currentLocale()
}}
}
</span>
</div>
@@ -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<typeof setTimeout> | null = null;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly currentTitle = input.required<string>();
readonly subtitle = input<string>('');
@@ -116,6 +124,12 @@ export class PlaylistSwitcherComponent {
});
readonly portalStatuses = signal<Map<string, PortalStatus>>(new Map());
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
});
constructor() {
this.destroyRef.onDestroy(() => {
@@ -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) {
<mat-icon
@@ -107,11 +113,17 @@
}
@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()
}}
}
}
</div>
@@ -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();
@@ -41,7 +41,7 @@
<h3>{{ title() }}</h3>
@if (date()) {
<p class="date-info">
{{ date() | date: 'd MMM, HH:mm' }}
{{ date() | date: 'd MMM, HH:mm' : undefined : currentLocale() }}
</p>
}
</div>
@@ -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<string>();
@@ -44,6 +56,12 @@ export class ContentCardComponent {
/** Emitted when the remove button is clicked */
readonly remove = output<void>();
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
});
/** Get the icon for the placeholder based on type */
getPlaceholderIcon(): string {
@@ -35,7 +35,10 @@
</div>
<div class="epg-timeline">
<span class="epg-time">{{
formatTime(program.start, program.startTimestamp)
(program.startTimestamp
? program.startTimestamp * 1000
: program.start
) | date: 'HH:mm'
}}</span>
<div class="epg-progress-track">
<div
@@ -46,7 +49,10 @@
></div>
</div>
<span class="epg-time">{{
formatTime(program.stop, program.stopTimestamp)
(program.stopTimestamp
? program.stopTimestamp * 1000
: program.stop
) | date: 'HH:mm'
}}</span>
</div>
} @else if (!isRadio()) {
@@ -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') ?? '',
]);
});
@@ -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
@@ -3,11 +3,26 @@
<header class="program-header">
<div class="time-block">
<div class="time-display">
<span class="time-start">{{ formatTime(epgProgram.start) }}</span>
<span class="time-start">{{
(epgProgram.startTimestamp
? epgProgram.startTimestamp * 1000
: epgProgram.start
) | date: 'HH:mm'
}}</span>
<span class="time-separator">—</span>
<span class="time-end">{{ formatTime(epgProgram.stop) }}</span>
<span class="time-end">{{
(epgProgram.stopTimestamp
? epgProgram.stopTimestamp * 1000
: epgProgram.stop
) | date: 'HH:mm'
}}</span>
</div>
<div class="date-display">{{ formatDate(epgProgram.start) }}</div>
<div class="date-display">{{
(epgProgram.startTimestamp
? epgProgram.startTimestamp * 1000
: epgProgram.start
) | date: 'EEEE, MMMM d' : undefined : currentLocale()
}}</div>
</div>
@if (channelName) {
<div class="channel-badge">
@@ -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<EpgItemDialogData>(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 {
@@ -1,7 +1,7 @@
<span class="time"
>{{ item.start | momentDate: 'HH:mm' }}
>{{ (item.startTimestamp ? item.startTimestamp * 1000 : item.start) | date: 'HH:mm' }}
-
{{ item.stop | momentDate: 'HH:mm' }}</span
{{ (item.stopTimestamp ? item.stopTimestamp * 1000 : item.stop) | date: 'HH:mm' }}</span
>
@if (isLive) {
<span class="live badge" [matTooltip]="'EPG.LIVE_NOW' | translate"
@@ -3,56 +3,23 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import moment from 'moment';
import { MockModule, MockPipe } from 'ng-mocks';
import { MockModule } from 'ng-mocks';
import { EpgProgram } from 'shared-interfaces';
import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
import { EpgListItemComponent } from './epg-list-item.component';
const EPG_PROGRAM_ITEM = {
start: moment(Date.now()).format('YYYYMMDD'),
stop: moment(Date.now()).format('YYYYMMDD'),
const EPG_PROGRAM_ITEM: EpgProgram = {
start: '2026-04-05T11:30:00.000Z',
stop: '2026-04-05T12:30:00.000Z',
channel: '12345',
title: [{ lang: 'en', value: 'NOW on PBS' }],
desc: [
{
lang: 'en',
value: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.",
},
],
date: ['20080711'],
category: [
{ lang: 'en', value: 'Newsmagazine' },
{ lang: 'en', value: 'Interview' },
],
episodeNum: [
{ system: 'dd_progid', value: 'EP01006886.0028' },
{ system: 'onscreen', value: '427' },
],
previouslyShown: [{ start: '20080711000000' }],
subtitles: [{ type: 'teletext' }],
rating: [
{
system: 'VCHIP',
value: 'TV-G',
},
],
credits: [
{
role: 'actor',
name: 'Peter Bergman',
},
],
icon: [
title: 'NOW on PBS',
desc: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.",
category: 'Newsmagazine',
episodeNum: '427',
rating: 'TV-G',
iconUrl:
'http://imageswoapi.whatsonindia.com/WhatsOnTV/images/ProgramImages/xlarge/38B4DE4E9A7132257749051B6C8B4F699DB264F4V.jpg',
],
audio: [],
_attributes: {
start: moment(Date.now()).format('YYYYMMDD'),
stop: moment(Date.now()).format('YYYYMMDD'),
},
};
describe('EpgListItemComponent', () => {
@@ -63,8 +30,8 @@ describe('EpgListItemComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [EpgListItemComponent, MockPipe(MomentDatePipe)],
imports: [
EpgListItemComponent,
MockModule(MatDialogModule),
MockModule(MatListModule),
MockModule(MatIconModule),
@@ -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'],
@@ -14,7 +14,8 @@
</button>
<span class="selected-date">
{{
selectedDate() | momentDate: 'MMMM Do, dddd' : 'YYYY-MM-DD'
selectedDate()
| date: 'MMMM d, EEEE' : undefined : currentLocale()
}}</span
>
<button
@@ -1,18 +1,21 @@
import { registerLocaleData } from '@angular/common';
import { Component, input } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import localeDe from '@angular/common/locales/de';
import { MockPipe } from 'ng-mocks';
import { BehaviorSubject, of } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import { MomentDatePipe } from '@iptvnator/pipes';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Channel, EpgProgram } from 'shared-interfaces';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
import { EpgListComponent } from './epg-list.component';
let requestAnimationFrameQueue: FrameRequestCallback[] = [];
registerLocaleData(localeDe, 'de');
@Component({
selector: 'app-epg-list-item',
standalone: true,
@@ -29,6 +32,11 @@ describe('EpgListComponent', () => {
let component: EpgListComponent;
let store: { select: jest.Mock; dispatch: jest.Mock };
let epgService: { currentEpgPrograms$: BehaviorSubject<EpgProgram[]> };
let translateService: {
currentLang: string;
defaultLang: string;
onLangChange: BehaviorSubject<unknown>;
};
let originalRequestAnimationFrame: typeof window.requestAnimationFrame;
const fixedNow = new Date('2026-04-05T12:00:00.000Z');
@@ -67,45 +75,27 @@ describe('EpgListComponent', () => {
epgService = {
currentEpgPrograms$: new BehaviorSubject<EpgProgram[]>([]),
};
translateService = {
currentLang: 'en',
defaultLang: 'en',
onLangChange: new BehaviorSubject<unknown>(null),
};
await TestBed.configureTestingModule({
imports: [EpgListComponent, NoopAnimationsModule],
providers: [
{ provide: Store, useValue: store },
{ provide: EpgService, useValue: epgService },
{ provide: TranslateService, useValue: translateService },
],
})
.overrideComponent(EpgListComponent, {
remove: {
imports: [
EpgListItemComponent,
MomentDatePipe,
TranslatePipe,
],
imports: [EpgListItemComponent, TranslatePipe],
},
add: {
imports: [
StubEpgListItemComponent,
MockPipe(
MomentDatePipe,
(
value: string | null | undefined,
format?: string
) => {
if (!value) {
return '';
}
const parsed = new Date(value);
if (format === 'HH:mm') {
return parsed
.toISOString()
.slice(11, 16);
}
return value.slice(0, 10);
}
),
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
@@ -238,6 +228,21 @@ describe('EpgListComponent', () => {
.length
).toBe(2);
});
it('updates the selected-day header when the app language changes', () => {
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
fixture.detectChanges();
translateService.currentLang = 'de';
translateService.onLangChange.next({ lang: 'de' });
fixture.detectChanges();
expect(
fixture.nativeElement
.querySelector('.selected-date')
.textContent.trim()
).toContain('Sonntag');
});
});
function buildPrograms(overrides?: {
@@ -1,3 +1,4 @@
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@@ -15,15 +16,16 @@ import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { EpgService } from '@iptvnator/epg/data-access';
import { MomentDatePipe } from '@iptvnator/pipes';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EpgActions, selectActive } from 'm3u-state';
import moment from 'moment';
import { addDays, format, subDays } from 'date-fns';
import { startWith } from 'rxjs';
import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
const DATE_FORMAT = 'YYYY-MM-DD';
const DATE_FORMAT = 'yyyy-MM-dd';
export interface EpgProgramActivationEvent {
program: EpgProgram;
@@ -32,11 +34,11 @@ export interface EpgProgramActivationEvent {
@Component({
imports: [
DatePipe,
EpgListItemComponent,
MatIcon,
MatIconButton,
MatTooltip,
MomentDatePipe,
TranslatePipe,
],
selector: 'app-epg-list',
@@ -53,6 +55,7 @@ export class EpgListComponent {
private readonly store = inject(Store);
private readonly epgService = inject(EpgService);
private readonly translate = inject(TranslateService);
private readonly activeChannel = toSignal(this.store.select(selectActive), {
initialValue: null,
@@ -61,11 +64,21 @@ export class EpgListComponent {
this.epgService.currentEpgPrograms$,
{ initialValue: [] as EpgProgram[] }
);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly programList = viewChild<ElementRef<HTMLElement>>('programList');
readonly selectedDate = signal(moment().format(DATE_FORMAT));
readonly selectedDate = signal(format(new Date(), DATE_FORMAT));
readonly timeNow = signal(new Date().toISOString());
readonly currentTimeMs = computed(() => Date.parse(this.timeNow()));
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
});
readonly isControlled = computed(
() =>
@@ -103,9 +116,7 @@ export class EpgListComponent {
return Math.max(0, Number(value ?? 0) || 0);
});
readonly timeshiftUntil = computed(() =>
moment()
.subtract(this.archiveDays(), 'days')
.toISOString()
subDays(new Date(), this.archiveDays()).toISOString()
);
readonly archivePlaybackEnabled = computed(
() => this.archivePlaybackAvailable() ?? (this.archiveDays() > 0)
@@ -114,7 +125,7 @@ export class EpgListComponent {
[...this.items()]
.filter(
(item) =>
moment(item.start).format(DATE_FORMAT) ===
getProgramDateKey(item.start, item.startTimestamp) ===
this.selectedDate()
)
.sort(
@@ -175,10 +186,14 @@ export class EpgListComponent {
}
changeDate(direction: 'next' | 'prev'): void {
const selectedDate = new Date(`${this.selectedDate()}T00:00:00`);
this.selectedDate.set(
moment(this.selectedDate())
[direction === 'next' ? 'add' : 'subtract'](1, 'days')
.format(DATE_FORMAT)
format(
direction === 'next'
? addDays(selectedDate, 1)
: subDays(selectedDate, 1),
DATE_FORMAT
)
);
}
@@ -274,7 +289,7 @@ export class EpgListComponent {
}
private scheduleScrollToCurrentProgram(): void {
if (this.selectedDate() !== moment().format(DATE_FORMAT)) {
if (this.selectedDate() !== format(new Date(), DATE_FORMAT)) {
return;
}
@@ -359,3 +374,16 @@ function getProgramTimeMs(
return Date.parse(isoValue);
}
function getProgramDateKey(
isoValue: string,
timestampValue?: number | null
): string {
const programTimeMs = getProgramTimeMs(isoValue, timestampValue);
if (!Number.isFinite(programTimeMs)) {
return '';
}
return format(new Date(programTimeMs), DATE_FORMAT);
}
@@ -14,7 +14,7 @@
<mat-icon>chevron_left</mat-icon>
</button>
<div class="today-date">
{{ today() | momentDate: 'MMMM Do, dddd' : 'YYYYMMDD' }}
{{ selectedDayDate() | date: 'MMMM d, EEEE' : undefined : currentLocale() }}
</div>
<button
[matTooltip]="'EPG.NEXT_DAY' | translate"
@@ -88,8 +88,14 @@
<div class="result-info">
<span class="result-title">{{ program.title }}</span>
<span class="result-meta">
<span class="result-time">{{ formatProgramTime(program) }}</span>
<span class="result-date">{{ formatProgramDate(program) }}</span>
<span class="result-time">
{{ (program.startTimestamp ? program.startTimestamp * 1000 : program.start) | date: 'HH:mm' }}
-
{{ (program.stopTimestamp ? program.stopTimestamp * 1000 : program.stop) | date: 'HH:mm' }}
</span>
<span class="result-date">
{{ (program.startTimestamp ? program.startTimestamp * 1000 : program.start) | date: 'MMM d' : undefined : currentLocale() }}
</span>
</span>
</div>
<mat-icon class="result-arrow">arrow_forward</mat-icon>
@@ -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<string | null>(null);
private searchDebounceTimer: ReturnType<typeof setTimeout> | 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();
}
+1 -1
View File
@@ -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';
@@ -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');
});
});
+52
View File
@@ -0,0 +1,52 @@
const DEFAULT_DATE_LOCALE = 'en';
const DATE_LOCALE_ALIASES: Record<string, string> = {
ary: 'ar-MA',
by: 'be',
zhtw: 'zh-Hant',
};
const formatterCache = new Map<string, Intl.DateTimeFormat>();
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);
}
@@ -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');
});
});
-25
View File
@@ -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);
}
}
@@ -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',
});
}
}
@@ -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',
});
}
}