fix(epg): keep the ribbon mounted when scrolling across a gap day

`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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 committed 2026-07-01 18:42:28 +02:00
1 parent d14c9a4a6f
commit 29e964d736
3 files changed
+78 -4

No files matched your search

@@ -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();
});
});
});
@@ -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);
}
});
@@ -121,9 +121,8 @@ export class EpgTimelineComponent {
private readonly nowMs = signal(Date.now());
readonly selectedKey = signal<string | null>(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(