From 29e964d736a91b8e884aa4f6edbd8a0bd36b7a4b Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 1 Jul 2026 18:42:28 +0200 Subject: [PATCH] fix(epg): keep the ribbon mounted when scrolling across a gap day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderState` only renders the ribbon when the centred day has programmes, and `onRibbonScroll` commits whichever day is centred. So in a multi-day window with a gap day (e.g. programmes Mon + Wed, none Tue), scrolling across the gap made `viewDayKey` a day with no programmes → state flipped to `empty-day` → the `@switch` unmounted the ribbon mid-scroll, stranding the user before the later programmes (stepper-only recovery). Flagged by Codex. Guard the scroll commit with a `hasProgramsForDay` predicate: skip gap days so the last populated day stays centred and the ribbon stays mounted until the user scrolls into another day that actually has content. Explicit date navigation (stepper / jump-to-nearest / controlled `selectedDate`) still shows `empty-day` as before — only the continuous-scroll path is affected. Tests: new onRibbonScroll specs assert a populated day commits and a gap day does not. ui-epg 82 green, lint clean, component 400 lines. Co-Authored-By: Claude Opus 4.8 --- .../epg-timeline-scroll.controller.spec.ts | 63 +++++++++++++++++++ .../epg-timeline-scroll.controller.ts | 12 +++- .../epg-timeline/epg-timeline.component.ts | 7 ++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts index fada1cd8d..aa7ae36bd 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts @@ -6,6 +6,8 @@ import { import { buildTimelineAxis, buildTimelineBlocks, + hasProgramsForDateKey, + TIMELINE_MINUTE_MS, } from './epg-timeline.utils'; function programAt( @@ -73,6 +75,8 @@ describe('TimelineScrollController', () => { nowMs: () => nowMs, viewDayKey: () => 'today', commitDay: commitDaySpy, + hasProgramsForDay: (dayKey) => + hasProgramsForDateKey(programs, dayKey), }); scrollSpy = jest .spyOn(controller, 'scrollToOffset') @@ -139,4 +143,63 @@ describe('TimelineScrollController', () => { expect(scrollSpy).not.toHaveBeenCalled(); }); }); + + describe('onRibbonScroll (gap-day handling)', () => { + let rafSpy: jest.SpyInstance; + let nowMs: number; + let programs: EpgProgram[]; + let scroller: { scrollLeft: number; clientWidth: number }; + + beforeEach(() => { + rafSpy = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + nowMs = Date.now(); + programs = [programAt(0, 120, 'Now')]; + // Centre the viewport on "now" (today) — a real, in-axis day-key. + const axis = buildTimelineAxis(programs, nowMs); + scroller = { + scrollLeft: (nowMs - axis.startMs) / TIMELINE_MINUTE_MS, + clientWidth: 0, + }; + }); + + afterEach(() => rafSpy.mockRestore()); + + function controllerWith( + hasProgramsForDay: (dayKey: string) => boolean, + commitDay: jest.Mock + ): TimelineScrollController { + return new TimelineScrollController({ + ribbon: () => scroller as unknown as HTMLElement, + scale: () => 1, + axis: () => buildTimelineAxis(programs, nowMs), + blocks: () => + buildTimelineBlocks( + programs, + buildTimelineAxis(programs, nowMs), + nowMs + ), + nowMs: () => nowMs, + viewDayKey: () => 'today', + commitDay, + hasProgramsForDay, + }); + } + + it('commits the centred day when it has programmes', () => { + const commitDay = jest.fn(); + controllerWith(() => true, commitDay).onRibbonScroll(); + expect(commitDay).toHaveBeenCalledTimes(1); + }); + + it('does not commit a gap day (keeps the ribbon mounted mid-scroll)', () => { + const commitDay = jest.fn(); + controllerWith(() => false, commitDay).onRibbonScroll(); + expect(commitDay).not.toHaveBeenCalled(); + }); + }); }); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts index 33c50c5d5..78be6327e 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts @@ -17,6 +17,8 @@ export interface TimelineScrollContext { readonly nowMs: () => number; readonly viewDayKey: () => string; readonly commitDay: (dayKey: string) => void; + /** Whether the given day-key has any programme in the loaded window. */ + readonly hasProgramsForDay: (dayKey: string) => boolean; } /** Stable identity of a channel's programme set (changes when the channel does). */ @@ -90,7 +92,15 @@ export class TimelineScrollController { (scroller.scrollLeft + scroller.clientWidth / 2) / this.ctx.scale(); const dayKey = dayKeyAtOffset(this.ctx.axis(), centerOffsetMin); - if (dayKey && dayKey !== this.ctx.viewDayKey()) { + // Skip gap days: committing a day with no programmes flips the host + // to `empty-day` and unmounts the ribbon mid-scroll, stranding the + // user before the later programmes. Keep the last populated day + // centred until they scroll into another day that has content. + if ( + dayKey && + dayKey !== this.ctx.viewDayKey() && + this.ctx.hasProgramsForDay(dayKey) + ) { this.ctx.commitDay(dayKey); } }); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts index 270a636b0..6f70851ae 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts @@ -121,9 +121,8 @@ export class EpgTimelineComponent { private readonly nowMs = signal(Date.now()); readonly selectedKey = signal(null); - /** Day centred in the ribbon. Seeded from the controlled `selectedDate` so a - * non-today date survives (re)mount and follows host changes; local - * navigation overrides via `commitDay`. Falls back to today when unset. */ + /** Day centred in the ribbon, seeded from the controlled `selectedDate` so + * a non-today date survives (re)mount and follows host changes. */ private readonly viewDayKey = linkedSignal(() => { const key = this.selectedDate()?.trim(); return key ? key : getTodayEpgDateKey(); @@ -138,6 +137,8 @@ export class EpgTimelineComponent { nowMs: () => this.nowMs(), viewDayKey: () => this.viewDayKey(), commitDay: (dayKey) => this.commitDay(dayKey), + hasProgramsForDay: (dayKey) => + hasProgramsForDateKey(this.programs(), dayKey), }); private readonly languageTick = toSignal(