fix(epg): restore timeline position when the ribbon remounts

Collapsing the inline EPG panel unmounts the ribbon (`@if (!collapsed())`);
expanding recreates the scroller at scrollLeft 0. `maybeAutoFocus` deduped the
same programme set and skipped, so the user landed at the far-left of the
multi-day guide instead of back on the live programme. Flagged by Codex.

Distinguish a real remount (a *new* scroller element) from a data re-emit /
now-tick (same element): on a new element for an already-focused channel,
re-centre the currently-viewed day — "now" for today, else the viewed day —
without committing a day, so a user parked on another day is not snapped back.
The same-element case is still left alone. Empty-today channels never set
lastFocusKey, so the ① snap-back protection is untouched.

Tests: rework the remount spec into a collapse/expand restore case (asserts a
re-centre with no day commit) and extend the empty-day guard across a remount;
a commitDay spy locks in "restore never snaps a day". ui-epg 80 green, lint
clean, controller 153 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:22:07 +02:00
1 parent 30696d037d
commit d14c9a4a6f
2 files changed
+51 -20

No files matched your search

@@ -54,10 +54,12 @@ describe('TimelineScrollController', () => {
let programs: EpgProgram[];
let controller: TimelineScrollController;
let scrollSpy: jest.SpyInstance;
let commitDaySpy: jest.Mock;
beforeEach(() => {
programs = [];
const nowMs = Date.now();
commitDaySpy = jest.fn();
controller = new TimelineScrollController({
ribbon: () => undefined,
scale: () => 1,
@@ -70,7 +72,7 @@ describe('TimelineScrollController', () => {
),
nowMs: () => nowMs,
viewDayKey: () => 'today',
commitDay: () => undefined,
commitDay: commitDaySpy,
});
scrollSpy = jest
.spyOn(controller, 'scrollToOffset')
@@ -102,16 +104,16 @@ describe('TimelineScrollController', () => {
expect(scrollSpy).toHaveBeenCalledTimes(1);
});
it('does not re-focus the same channel when its ribbon remounts (no snap-back)', () => {
// Unmount + remount of the SAME channel — e.g. the user navigates from
// an empty day to a day with programmes — must not re-focus, or it
// would commit today again and snap the view back to the empty day.
it('restores position when the same channel ribbon remounts (collapse then expand)', () => {
// Collapse → expand recreates the ribbon as a *new* element at
// scrollLeft 0, so re-centre the viewed programme — but WITHOUT
// committing a day, so a user parked on another day is not snapped.
const p = [programAt(0, 120, 'Now')];
const scroller = {} as HTMLElement;
focus(scroller, p); // focus #1
focus(undefined, p); // ribbon unmounts (empty-day navigation)
focus(scroller, p); // remount on another day → must NOT re-focus
expect(scrollSpy).toHaveBeenCalledTimes(1);
focus({} as HTMLElement, p); // initial mount → focus #1
focus(undefined, p); // collapse: ribbon unmounts
focus({} as HTMLElement, p); // expand: new element → restore
expect(scrollSpy).toHaveBeenCalledTimes(2);
expect(commitDaySpy).toHaveBeenCalledTimes(1); // only the initial focus
});
it('re-focuses when the channel changes', () => {
@@ -123,9 +125,13 @@ describe('TimelineScrollController', () => {
it('does not focus or snap to today when today has no programmes', () => {
// Programmes only three days out → today is empty; auto-focus must
// leave the user's day navigation alone instead of forcing today.
focus({} as HTMLElement, [programAt(3 * 1440, 60)]);
// leave the user's day navigation alone instead of forcing today —
// even across a collapse/expand remount (a new scroller element).
const p = [programAt(3 * 1440, 60)];
focus({} as HTMLElement, p);
focus({} as HTMLElement, p); // remount → still no focus/snap
expect(scrollSpy).not.toHaveBeenCalled();
expect(commitDaySpy).not.toHaveBeenCalled();
});
it('skips focus entirely when the ribbon is not mounted', () => {
@@ -37,6 +37,7 @@ export function programsFocusKey(programs: readonly EpgProgram[]): string {
export class TimelineScrollController {
private scrollFrame = 0;
private lastFocusKey: string | null = null;
private lastScroller: HTMLElement | null = null;
constructor(private readonly ctx: TimelineScrollContext) {}
@@ -51,12 +52,12 @@ export class TimelineScrollController {
this.scrollToOffset(offsetMin, 0.5, smooth);
}
scrollToDateKey(dateKey: string, frac: number): void {
scrollToDateKey(dateKey: string, frac: number, smooth = true): void {
const axis = this.ctx.axis();
const noonMs =
parseEpgDateKey(dateKey).getTime() + 12 * 60 * TIMELINE_MINUTE_MS;
const offsetMin = (noonMs - axis.startMs) / TIMELINE_MINUTE_MS;
this.scrollToOffset(offsetMin, frac);
this.scrollToOffset(offsetMin, frac, smooth);
}
scrollToOffset(offsetMin: number, frac: number, smooth = true): void {
@@ -105,12 +106,22 @@ export class TimelineScrollController {
programs: readonly EpgProgram[]
): void {
const key = programsFocusKey(programs);
// Skip (without clearing lastFocusKey) when there's no ribbon yet, no
// programmes, or we've already focused this channel. Re-running for the
// same channel — e.g. when the ribbon remounts after the user navigates
// to another day — must NOT re-focus, or `commitDay(today)` below would
// snap the view back to today and trap the user on an empty day.
if (!scroller || !key || key === this.lastFocusKey) {
// Nothing to centre without a ribbon or programmes.
if (!scroller || !key) {
return;
}
if (key === this.lastFocusKey) {
// Same channel already focused. A *new* scroller element means the
// ribbon was unmounted then remounted (e.g. the inline panel was
// collapsed and re-expanded), which resets scrollLeft to 0 — restore
// the viewed day without committing a different day, so the user
// lands back on their programme rather than the far-left of the
// guide. The same element (data re-emit / now-tick) is left alone so
// we never yank the viewport out from under the user.
if (scroller !== this.lastScroller) {
this.lastScroller = scroller;
this.restorePosition();
}
return;
}
const todayKey = getTodayEpgDateKey();
@@ -121,8 +132,22 @@ export class TimelineScrollController {
return;
}
this.lastFocusKey = key;
this.lastScroller = scroller;
this.ctx.commitDay(todayKey);
// Instant: land already centred, no annoying scroll animation.
this.focusCurrentProgram(false);
}
/**
* Re-centre the currently-viewed day after the ribbon remounts (which resets
* scrollLeft). Restores "now" for today, otherwise the viewed day — never
* commits a different day, so a user parked on another day stays there.
*/
private restorePosition(): void {
if (this.ctx.viewDayKey() === getTodayEpgDateKey()) {
this.focusCurrentProgram(false);
} else {
this.scrollToDateKey(this.ctx.viewDayKey(), 0.5, false);
}
}
}