mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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:
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(
|
||||
|
||||
Reference in new issue
Block a user