fix(ui-epg): hide duplicate program time slots

This commit is contained in:
4gray committed 2026-06-28 13:36:20 +02:00
1 parent 931a3243c8
commit cc85d84a0e
3 files changed
+125 -12

No files matched your search

@@ -321,6 +321,62 @@ describe('EpgListComponent', () => {
}
});
it('shows one row per duplicated time slot and keeps the richer program entry', () => {
const sparseProgram = buildProgram(
'duplicate-a',
'World Cup Breakfast',
'2026-04-05T09:00:00.000Z',
'2026-04-05T10:00:00.000Z',
{
channel: 'channel-101',
desc: null,
}
);
const richProgram = buildProgram(
'duplicate-b',
'World Cup Breakfast',
'2026-04-05T09:00:00.000Z',
'2026-04-05T10:00:00.000Z',
{
channel: 'channel-101',
desc: 'A preview of the day ahead.',
category: 'Sports',
}
);
const nextProgram = buildProgram(
'next',
'World Cup Matchday',
'2026-04-05T10:00:00.000Z',
'2026-04-05T11:00:00.000Z',
{
channel: 'channel-101',
}
);
const emitted: unknown[] = [];
component.programActivated.subscribe((event) => emitted.push(event));
fixture.componentRef.setInput('controlledPrograms', [
sparseProgram,
richProgram,
nextProgram,
]);
fixture.componentRef.setInput('archivePlaybackAvailable', true);
fixture.detectChanges();
const rows =
fixture.nativeElement.querySelectorAll<HTMLElement>(
'.program-item'
);
rows[0].click();
expect(component.filteredItems()).toEqual([
richProgram,
nextProgram,
]);
expect(rows).toHaveLength(2);
expect(emitted).toEqual([{ program: richProgram, type: 'timeshift' }]);
});
it('updates the selected-day header when the app language changes', () => {
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
fixture.detectChanges();
@@ -389,7 +445,8 @@ function buildProgram(
channelSuffix: string,
title: string,
start: string,
stop: string
stop: string,
overrides: Partial<EpgProgram> = {}
): EpgProgram {
return {
start,
@@ -400,6 +457,7 @@ function buildProgram(
category: null,
startTimestamp: Math.floor(Date.parse(start) / 1000),
stopTimestamp: Math.floor(Date.parse(stop) / 1000),
...overrides,
};
}
@@ -32,6 +32,7 @@ import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
import {
areProgramsSame,
buildScrollContextKey,
deduplicateProgramsByTimeSlot,
getProgramDateKey,
getProgramTimeMs,
trackProgram,
@@ -144,17 +145,19 @@ export class EpgListComponent {
() => this.archivePlaybackAvailable() ?? this.archiveDays() > 0
);
readonly filteredItems = computed(() =>
[...this.items()]
.filter(
(item) =>
getProgramDateKey(item.start, item.startTimestamp) ===
this.selectedDateKey()
)
.sort(
(left, right) =>
getProgramTimeMs(left.start, left.startTimestamp) -
getProgramTimeMs(right.start, right.startTimestamp)
)
deduplicateProgramsByTimeSlot(
[...this.items()]
.filter(
(item) =>
getProgramDateKey(item.start, item.startTimestamp) ===
this.selectedDateKey()
)
.sort(
(left, right) =>
getProgramTimeMs(left.start, left.startTimestamp) -
getProgramTimeMs(right.start, right.startTimestamp)
)
)
);
private scrollScheduled = false;
@@ -15,6 +15,26 @@ export function trackProgram(index: number, program: EpgProgram): string {
].join('|');
}
export function deduplicateProgramsByTimeSlot(
programs: EpgProgram[]
): EpgProgram[] {
const programsByTimeSlot = new Map<string, EpgProgram>();
for (const program of programs) {
const timeSlotKey = buildProgramTimeSlotKey(program);
const existingProgram = programsByTimeSlot.get(timeSlotKey);
programsByTimeSlot.set(
timeSlotKey,
existingProgram
? selectMoreInformativeProgram(existingProgram, program)
: program
);
}
return Array.from(programsByTimeSlot.values());
}
export function buildScrollContextKey(
channel: Channel | null,
programs: EpgProgram[]
@@ -71,3 +91,35 @@ export function areProgramsSame(
getProgramTimeMs(right.stop, right.stopTimestamp)
);
}
function buildProgramTimeSlotKey(program: EpgProgram): string {
return [
getProgramTimeMs(program.start, program.startTimestamp),
getProgramTimeMs(program.stop, program.stopTimestamp),
].join('|');
}
function selectMoreInformativeProgram(
existingProgram: EpgProgram,
candidateProgram: EpgProgram
): EpgProgram {
return getProgramMetadataScore(candidateProgram) >
getProgramMetadataScore(existingProgram)
? candidateProgram
: existingProgram;
}
function getProgramMetadataScore(program: EpgProgram): number {
return (
getTextScore(program.desc) * 8 +
getTextScore(program.category) * 4 +
getTextScore(program.iconUrl) * 2 +
getTextScore(program.rating) * 2 +
getTextScore(program.episodeNum) * 2 +
getTextScore(program.title)
);
}
function getTextScore(value: string | null | undefined): number {
return value?.trim() ? 1 : 0;
}