fix(epg): estimate the ribbon window once per channel or mount

The live estimate followed the centred day and the 30 s now tick while the
ribbon was not yet scrolled: a small scroll across midnight re-centred the
window on the next day's noon and left the visible range empty, and the
host width was re-read (a forced layout) on every tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-04 11:40:17 +02:00
1 parent 97d9847aa5
commit ca72d75e86
2 files changed
+33 -7

No files matched your search

@@ -331,6 +331,29 @@ describe('EpgTimelineComponent ribbon windowing', () => {
expect(renderedTitles()).not.toContain('Slot 0');
});
it('keeps the window where the ribbon is when a small scroll commits another day', () => {
// A scroll under the re-window step can still cross midnight; the
// committed day must not re-centre the window on its noon.
render(mockSchedule());
const tomorrow = new Date(Date.now() + 24 * 60 * TIMELINE_MINUTE_MS);
const pad = (n: number) => String(n).padStart(2, '0');
component.viewDayKey.set(
`${tomorrow.getFullYear()}-${pad(tomorrow.getMonth() + 1)}-${pad(tomorrow.getDate())}`
);
fixture.detectChanges();
expect(renderedTitles()).toContain('Slot 96');
});
it('does not follow the now tick away from an unscrolled ribbon', () => {
render(mockSchedule());
component.nowMs.set(Date.now() + 12 * 60 * TIMELINE_MINUTE_MS);
fixture.detectChanges();
expect(renderedTitles()).toContain('Slot 96');
});
it('starts over around now when another channel loads', () => {
render(mockSchedule());
scrollRibbonTo(blockLeftPx('Slot 230'));
@@ -5,6 +5,7 @@ import {
inject,
linkedSignal,
Signal,
untracked,
} from '@angular/core';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { getTodayEpgDateKey, parseEpgDateKey } from '../epg-date';
@@ -154,13 +155,15 @@ export class TimelineWindowController {
left.ribbon === right.ribbon && left.key === right.key,
}
);
private readonly measured = linkedSignal<unknown, TimelineViewport | null>({
/**
* Estimated once per channel or ribbon mount, then moved only by the
* scroll and resize measurements: a live estimate would follow the 30 s
* now tick and the centred day away from what the ribbon shows.
*/
private readonly viewport = linkedSignal<unknown, TimelineViewport>({
source: this.identity,
computation: () => null,
computation: () => untracked(() => this.initialViewport()),
});
private readonly viewport = computed(
() => this.measured() ?? this.initialViewport()
);
private readonly range = computed(() =>
timelineWindowRange(this.viewport(), this.ctx.axis(), this.ctx.scale())
);
@@ -221,7 +224,7 @@ export class TimelineWindowController {
const widthPx = this.scroller()?.clientWidth ?? 0;
const current = this.viewport();
if (widthPx > current.widthPx) {
this.measured.set({ centreMs: current.centreMs, widthPx });
this.viewport.set({ centreMs: current.centreMs, widthPx });
}
}
@@ -239,7 +242,7 @@ export class TimelineWindowController {
scale
);
if (viewportNeedsWindow(this.viewport(), next, scale)) {
this.measured.set(next);
this.viewport.set(next);
}
}