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 1e11b461c..6f56ef7eb 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 @@ -35,7 +35,7 @@
@if (items.length > 0) { - @for (program of items; track program.start) { + @for (program of items; track trackProgram($index, program)) { @if (!canActivateProgram(program)) {
{ expect(emitted).toEqual([{ program: programs[0], type: 'timeshift' }]); }); + it('renders duplicate-start programs without duplicate track-key errors and keeps clicks bound to each row', () => { + const programs = [ + buildProgram( + 'duplicate-a', + 'Duplicate Start A', + '2026-04-05T09:00:00.000Z', + '2026-04-05T09:30:00.000Z' + ), + buildProgram( + 'duplicate-b', + 'Duplicate Start B', + '2026-04-05T09:00:00.000Z', + '2026-04-05T10:00:00.000Z' + ), + buildProgram( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]; + const emitted: unknown[] = []; + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + component.programActivated.subscribe((event) => emitted.push(event)); + fixture.componentRef.setInput('controlledPrograms', programs); + fixture.componentRef.setInput('archivePlaybackAvailable', true); + + try { + expect(component.trackProgram(0, programs[0])).not.toBe( + component.trackProgram(1, programs[1]) + ); + + fixture.detectChanges(); + + const duplicateTrackKeyError = consoleErrorSpy.mock.calls.some( + (args) => args.some((arg) => String(arg).includes('NG0955')) + ); + const rows = + fixture.nativeElement.querySelectorAll( + '.program-item.clickable' + ); + rows[1].click(); + + expect(duplicateTrackKeyError).toBe(false); + expect(rows).toHaveLength(3); + expect(emitted).toEqual([ + { program: programs[1], type: 'timeshift' }, + ]); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + it('updates the selected-day header when the app language changes', () => { fixture.componentRef.setInput('controlledPrograms', buildPrograms()); fixture.detectChanges(); diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts index 6a886a8ec..890819a69 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts @@ -20,16 +20,22 @@ import { normalizeDateLocale } from '@iptvnator/pipes'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EpgActions, selectActive } from '@iptvnator/m3u-state'; -import { format, subDays } from 'date-fns'; +import { subDays } from 'date-fns'; import { startWith } from 'rxjs'; import { Channel, EpgChannel, EpgProgram } from '@iptvnator/shared/interfaces'; import { - EPG_DATE_KEY_FORMAT, EpgDateNavigationDirection, getTodayEpgDateKey, shiftEpgDateKey, } from '../epg-date'; import { EpgListItemComponent } from './epg-list-item/epg-list-item.component'; +import { + areProgramsSame, + buildScrollContextKey, + getProgramDateKey, + getProgramTimeMs, + trackProgram, +} from './epg-list.utils'; export interface EpgProgramActivationEvent { program: EpgProgram; @@ -62,6 +68,7 @@ export class EpgListComponent { readonly showDateNavigator = input(true); readonly programActivated = output(); readonly selectedDateChange = output(); + readonly trackProgram = trackProgram; private readonly store = inject(Store); private readonly epgService = inject(EpgService); @@ -371,57 +378,3 @@ export class EpgListComponent { } } } - -function buildScrollContextKey( - channel: Channel | null, - programs: EpgProgram[] -): string | null { - if (!channel && programs.length === 0) { - return null; - } - - const channelKey = - channel?.tvg?.id || channel?.name || channel?.url || 'unknown-channel'; - const programKey = programs - .map( - (program) => - `${getProgramTimeMs(program.start, program.startTimestamp)}-${getProgramTimeMs(program.stop, program.stopTimestamp)}` - ) - .join('|'); - - return `${channelKey}:${programKey}`; -} - -function getProgramTimeMs( - isoValue: string, - timestampValue?: number | null -): number { - if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) { - return Number(timestampValue) * 1000; - } - - 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), EPG_DATE_KEY_FORMAT); -} - -function areProgramsSame(left: EpgProgram, right: EpgProgram): boolean { - return ( - (left.channel ?? '') === (right.channel ?? '') && - getProgramTimeMs(left.start, left.startTimestamp) === - getProgramTimeMs(right.start, right.startTimestamp) && - getProgramTimeMs(left.stop, left.stopTimestamp) === - getProgramTimeMs(right.stop, right.stopTimestamp) - ); -} diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts b/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts new file mode 100644 index 000000000..a901f6d30 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts @@ -0,0 +1,73 @@ +import { format } from 'date-fns'; +import type { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { EPG_DATE_KEY_FORMAT } from '../epg-date'; + +export function trackProgram(index: number, program: EpgProgram): string { + const start = getProgramTimeMs(program.start, program.startTimestamp); + const stop = getProgramTimeMs(program.stop, program.stopTimestamp); + + return [ + program.channel ?? '', + Number.isFinite(start) ? start : program.start, + Number.isFinite(stop) ? stop : program.stop, + program.title ?? '', + index, + ].join('|'); +} + +export function buildScrollContextKey( + channel: Channel | null, + programs: EpgProgram[] +): string | null { + if (!channel && programs.length === 0) { + return null; + } + + const channelKey = + channel?.tvg?.id || channel?.name || channel?.url || 'unknown-channel'; + const programKey = programs + .map( + (program) => + `${getProgramTimeMs(program.start, program.startTimestamp)}-${getProgramTimeMs(program.stop, program.stopTimestamp)}` + ) + .join('|'); + + return `${channelKey}:${programKey}`; +} + +export function getProgramTimeMs( + isoValue: string, + timestampValue?: number | null +): number { + if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) { + return Number(timestampValue) * 1000; + } + + return Date.parse(isoValue); +} + +export function getProgramDateKey( + isoValue: string, + timestampValue?: number | null +): string { + const programTimeMs = getProgramTimeMs(isoValue, timestampValue); + + if (!Number.isFinite(programTimeMs)) { + return ''; + } + + return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT); +} + +export function areProgramsSame( + left: EpgProgram, + right: EpgProgram +): boolean { + return ( + (left.channel ?? '') === (right.channel ?? '') && + getProgramTimeMs(left.start, left.startTimestamp) === + getProgramTimeMs(right.start, right.startTimestamp) && + getProgramTimeMs(left.stop, left.stopTimestamp) === + getProgramTimeMs(right.stop, right.stopTimestamp) + ); +}