From 226ac7ee14f7cfa643104863c83233e619a8a7fc Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 7 Oct 2026 21:30:00 +0200 Subject: [PATCH] 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 --- .../epg-timeline-window.controller.spec.ts | 20 ++++++++++ .../epg-timeline-window.controller.ts | 38 +++++++++++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts index 482de63da..eb203605b 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts @@ -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 diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts index 77c2ff918..61307a6a6 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts @@ -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({ + 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; }