From ca72d75e8669a0ad693b4b9fa4d1a070c61ac615 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 4 Oct 2026 11:40:17 +0200 Subject: [PATCH] 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 --- .../epg-timeline-window.controller.spec.ts | 23 +++++++++++++++++++ .../epg-timeline-window.controller.ts | 17 ++++++++------ 2 files changed, 33 insertions(+), 7 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 0725929f8..3c6d89321 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 @@ -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')); 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 0319d96b4..db33558dc 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 @@ -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({ + /** + * 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({ 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); } }