fix(epg): avoid duplicate track keys for archive rows (#1096)

* fix(epg): avoid duplicate track keys for archive rows

* refactor(epg): extract epg list helpers
This commit is contained in:
4gray authored and GitHub committed 2026-06-28 12:37:42 +02:00
1 parent 148affdfca
commit a84090026b
4 files changed
+138 -57

No files matched your search

@@ -35,7 +35,7 @@
<!-- program list -->
<div id="program-list" class="app-scrollbar" #programList>
@if (items.length > 0) {
@for (program of items; track program.start) {
@for (program of items; track trackProgram($index, program)) {
@if (!canActivateProgram(program)) {
<div
class="program-item"
@@ -266,6 +266,61 @@ describe('EpgListComponent', () => {
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<HTMLElement>(
'.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();
@@ -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<EpgProgramActivationEvent>();
readonly selectedDateChange = output<string>();
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)
);
}
@@ -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)
);
}