fix(epg): re-measure the ribbon window when the axis origin moves

Review follow-up (Codex): a time offset that carries the first programme
across midnight moves the axis origin and every track position while
scrollLeft and the ribbon stay put, so no scroll event fires and the
window kept its epoch-time centre at a different pixel position. The
window identity now includes the axis start; when only that changes,
the viewport is measured from the ribbon instead of re-estimated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-07 21:30:00 +02:00
1 parent d9f95ec1ca
commit 226ac7ee14
2 files changed
+54 -4

No files matched your search

@@ -345,6 +345,26 @@ describe('EpgTimelineComponent ribbon windowing', () => {
expect(renderedTitles()).toContain('Slot 239');
});
// A time offset can carry the first programme across midnight: the axis
// origin and every track position move under an unchanged scrollLeft,
// and no scroll event follows.
it('re-measures the ribbon when the axis origin moves', () => {
render(midnightSchedule());
const startBefore = component.axis().startMs;
const left = ribbon().scrollLeft;
fixture.componentRef.setInput('offsetMinutes', -720);
fixture.detectChanges();
expect(component.axis().startMs).not.toBe(startBefore);
expect(ribbon().scrollLeft).toBe(left);
const visible = itemsIn(left, ribbonWidth);
expect(visible.length).toBeGreaterThan(0);
expect(component.ribbonWindow.items().map((e) => e.key)).toEqual(
expect.arrayContaining(visible)
);
});
it('windows an expanded group before the ribbon scrolls to it', () => {
render(mockSchedule());
const first = component
@@ -149,20 +149,36 @@ export class TimelineWindowController {
() => ({
ribbon: this.scroller(),
key: programsFocusKey(this.ctx.programs()),
axisStartMs: this.ctx.axis().startMs,
}),
{
equal: (left, right) =>
left.ribbon === right.ribbon && left.key === right.key,
left.ribbon === right.ribbon &&
left.key === right.key &&
left.axisStartMs === right.axisStartMs,
}
);
/**
* 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.
* now tick and the centred day away from what the ribbon shows. A moved
* axis origin (a time offset carrying the first programme across
* midnight) shifts every track position under an unchanged `scrollLeft`,
* with no scroll event, so the viewport is measured from the ribbon again.
*/
private readonly viewport = linkedSignal<unknown, TimelineViewport>({
private readonly viewport = linkedSignal<
{ ribbon: HTMLElement | undefined; key: string; axisStartMs: number },
TimelineViewport
>({
source: this.identity,
computation: () => untracked(() => this.initialViewport()),
computation: (source, previous) =>
untracked(() =>
previous &&
previous.source.ribbon === source.ribbon &&
previous.source.key === source.key
? (this.measuredViewport() ?? previous.value)
: this.initialViewport()
),
});
private readonly range = computed(() =>
timelineWindowRange(this.viewport(), this.ctx.axis(), this.ctx.scale())
@@ -260,6 +276,20 @@ export class TimelineWindowController {
}
}
/** The viewport the ribbon shows now; null before it is laid out. */
private measuredViewport(): TimelineViewport | null {
const scroller = this.scroller();
if (!scroller || scroller.clientWidth <= 0) {
return null;
}
return viewportFromScroller(
scroller.scrollLeft,
scroller.clientWidth,
this.ctx.axis(),
this.ctx.scale()
);
}
private scroller(): HTMLElement | undefined {
return this.ctx.ribbon()?.nativeElement;
}