From cc85d84a0e0d0a30020ec046379c3cc58970601c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 28 Jun 2026 13:36:20 +0200 Subject: [PATCH] fix(ui-epg): hide duplicate program time slots --- .../lib/epg-list/epg-list.component.spec.ts | 60 ++++++++++++++++++- .../src/lib/epg-list/epg-list.component.ts | 25 ++++---- .../ui/epg/src/lib/epg-list/epg-list.utils.ts | 52 ++++++++++++++++ 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts index 67084cd3b..9db0c2721 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts @@ -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( + '.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 { 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, }; } 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 890819a69..739093f0f 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 @@ -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; 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 index a901f6d30..463181137 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts @@ -15,6 +15,26 @@ export function trackProgram(index: number, program: EpgProgram): string { ].join('|'); } +export function deduplicateProgramsByTimeSlot( + programs: EpgProgram[] +): EpgProgram[] { + const programsByTimeSlot = new Map(); + + 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; +}