From 19aee624f7e9262fa0de9efd34220ab8d4b70c48 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 12:30:04 +0200 Subject: [PATCH] fix(epg): make guide geometry DST-safe and tighten the contract Co-Authored-By: Claude Fable 5.1 --- .../epg-guide/epg-guide-layout.util.spec.ts | 85 +++++++++++++++++-- .../lib/epg-guide/epg-guide-layout.util.ts | 55 ++++++++++-- .../epg-guide/epg-guide-preferences.spec.ts | 7 +- .../lib/epg-guide/epg-guide-preferences.ts | 24 +++++- .../epg/src/lib/epg-guide/epg-guide-source.ts | 23 +++-- 5 files changed, 169 insertions(+), 25 deletions(-) diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts index 50cd57aba..97862f59f 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts @@ -40,26 +40,56 @@ describe('epg-guide-layout.util', () => { leftPx: 120, kind: 'half', }); - expect(guideTrackWidthPx(240)).toBe(5760); + expect(guideTrackWidthPx(axis, 240)).toBe(5760); }); it('maps instants to x by the hour width and hides "now" outside the day', () => { expect(guideXForMs(axis, axis.startMs + 2 * HOUR, 240)).toBe(480); expect(guideNowLeftPx(axis, axis.startMs + HOUR, 240)).toBe(240); + expect(guideNowLeftPx(axis, axis.startMs, 240)).toBe(0); expect(guideNowLeftPx(axis, axis.startMs - 1, 240)).toBeNull(); expect(guideNowLeftPx(axis, axis.endMs, 240)).toBeNull(); }); + // A DST transition day is 23 or 25 hours, so `endMs - startMs` is not a + // fixed 24h — track width and tick count must scale with the axis span + // rather than assuming a fixed day length. `TZ` cannot be pinned per test + // in a running Node process, so these assert relative invariants that + // hold in every timezone, and actually exercise the 23h/25h cases when + // the test machine is in a European zone (2026-10-25 falls back, + // 2026-03-29 springs forward). + it('derives track width and tick count from the axis span, not a fixed 24h day', () => { + for (const dayKey of ['2026-10-25', '2026-03-29']) { + const dstAxis = buildGuideDayAxis(dayKey); + const spanHours = (dstAxis.endMs - dstAxis.startMs) / HOUR; + expect(guideTrackWidthPx(dstAxis, 240)).toBe(spanHours * 240); + expect(buildGuideTicks(dstAxis, 240)).toHaveLength(spanHours * 2); + } + }); + it('lays out programmes overlapping the day, including boundary crossers, with tiers', () => { const nowMs = axis.startMs + 16 * HOUR + 4 * 60_000; const blocks = buildGuideRowBlocks( [ program(axis.startMs - HOUR, axis.startMs + HOUR, 'Crosser'), - program(axis.startMs + 16 * HOUR, axis.startMs + 16.75 * HOUR, 'Now'), - program(axis.startMs + 17 * HOUR, axis.startMs + 17 * HOUR + 5 * 60_000, 'Micro'), + program( + axis.startMs + 16 * HOUR, + axis.startMs + 16.75 * HOUR, + 'Now' + ), + program( + axis.startMs + 17 * HOUR, + axis.startMs + 17 * HOUR + 5 * 60_000, + 'Micro' + ), program(axis.endMs + HOUR, axis.endMs + 2 * HOUR, 'Tomorrow'), ], - { axis, hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT, nowMs, offsetMinutes: 0 } + { + axis, + hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT, + nowMs, + offsetMinutes: 0, + } ); expect(blocks.map((block) => block.block.program.title)).toEqual([ 'Crosser', @@ -69,7 +99,52 @@ describe('epg-guide-layout.util', () => { expect(blocks[0].leftPx).toBe(-240); expect(blocks[1].block.when).toBe('now'); expect(blocks[1].nowFillPercent).toBeCloseTo((4 / 45) * 100, 3); - expect(blocks[2].tier).toBe('narrow'); + // 5 min at 240px/h = 20px raw, below the guide's 30px "micro" cutoff + // now that the guide floor (14px) no longer bumps it up to 40px. + expect(blocks[2].tier).toBe('micro'); + expect(blocks[2].widthPx).toBe(17); + }); + + it('reports "narrow" just above the micro cutoff', () => { + // 15 min at 240px/h = 60px raw, in the [30, 70) "narrow" band. + const blocks = buildGuideRowBlocks( + [program(axis.startMs, axis.startMs + 15 * 60_000, 'Narrow')], + { + axis, + hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT, + nowMs: axis.startMs, + offsetMinutes: 0, + } + ); + expect(blocks[0].tier).toBe('narrow'); + expect(blocks[0].widthPx).toBe(57); + }); + + it('excludes programmes touching the axis only at its boundaries', () => { + const blocks = buildGuideRowBlocks( + [ + program(axis.startMs - HOUR, axis.startMs, 'EndsAtStart'), + program(axis.endMs, axis.endMs + HOUR, 'StartsAtEnd'), + ], + { + axis, + hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT, + nowMs: axis.startMs, + offsetMinutes: 0, + } + ); + expect(blocks).toEqual([]); + }); + + it('returns an empty layout for no programmes', () => { + expect( + buildGuideRowBlocks([], { + axis, + hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT, + nowMs: axis.startMs, + offsetMinutes: 0, + }) + ).toEqual([]); }); it('shifts programme times by the display offset before layout', () => { diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts index e6b1df8e0..0e60ab298 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts @@ -27,6 +27,15 @@ export const EPG_GUIDE_CHANNEL_COLUMN_PX = 232; /** Rows loaded ahead of the rendered range, in each direction. */ export const EPG_GUIDE_ROW_BUFFER = 10; +/** + * Guide rows are shorter (44–60px) than the timeline ribbon, so the shared + * `TIMELINE_MIN_BLOCK_WIDTH_PX`/`TIMELINE_BLOCK_GAP_PX` floor (40px/4px) is + * too generous here — it would make `tierFor`'s micro branch unreachable at + * common zoom levels. The guide uses its own, tighter floor and gap. + */ +export const EPG_GUIDE_MIN_BLOCK_WIDTH_PX = 14; +export const EPG_GUIDE_BLOCK_GAP_PX = 3; + /** One selected day in DISPLAY time (local midnight to local midnight). */ export interface EpgGuideDayAxis extends TimelineAxis { readonly dayKey: string; @@ -46,6 +55,12 @@ export interface EpgGuideRowLayoutOptions { readonly catchUpAvailable?: boolean; } +/** + * A DST transition day is 23 or 25 hours, not 24 — `addDays` walks the + * calendar (local midnight to local midnight), so `endMs - startMs` is not a + * fixed constant. `guideTrackWidthPx` and `buildGuideTicks` below derive their + * geometry from that span instead of assuming a 24-hour day. + */ export function buildGuideDayAxis(dayKey: string): EpgGuideDayAxis { const start = parseEpgDateKey(dayKey); return { @@ -55,8 +70,11 @@ export function buildGuideDayAxis(dayKey: string): EpgGuideDayAxis { }; } -export function guideTrackWidthPx(hourWidthPx: number): number { - return hourWidthPx * 24; +export function guideTrackWidthPx( + axis: TimelineAxis, + hourWidthPx: number +): number { + return ((axis.endMs - axis.startMs) / HOUR_MS) * hourWidthPx; } export function guideXForMs( @@ -79,18 +97,28 @@ export function guideNowLeftPx( return guideXForMs(axis, nowMs, hourWidthPx); } +/** + * Ticks every 30 minutes from the axis start until the axis end, which — on a + * DST transition day — is not a multiple of 1440 minutes of wall-clock time. + * Iterating by adding 30-minute steps to the start `Date` (rather than + * looping a fixed minute count) keeps every tick's local time correct across + * the transition. + */ export function buildGuideTicks( axis: TimelineAxis, hourWidthPx: number ): EpgGuideTick[] { const ticks: EpgGuideTick[] = []; const start = new Date(axis.startMs); - for (let minute = 0; minute < 24 * 60; minute += 30) { - const ms = addMinutes(start, minute).getTime(); + for (let index = 0; ; index += 1) { + const ms = addMinutes(start, index * 30).getTime(); + if (ms >= axis.endMs) { + break; + } ticks.push({ ms, leftPx: guideXForMs(axis, ms, hourWidthPx), - kind: minute % 60 === 0 ? 'hour' : 'half', + kind: new Date(ms).getMinutes() === 0 ? 'hour' : 'half', }); } return ticks; @@ -99,10 +127,17 @@ export function buildGuideTicks( /** * Positioned blocks for one channel row, sharing the timeline's block maths * (`buildTimelineBlocks` → `buildTimelineRenderItems`) so both guides agree - * on tiers, minimum widths and the on-now fill. Programmes are shifted into - * display time by `offsetMinutes` and compared with the wall-clock `nowMs` - * (the display form of the EPG offset contract). Short-run grouping is off: - * a grid row has no room for group chips. + * on tiers and the on-now fill, though the guide applies its own tighter + * minimum width and gap (`EPG_GUIDE_MIN_BLOCK_WIDTH_PX`/ + * `EPG_GUIDE_BLOCK_GAP_PX`) sized for its shorter rows. Programmes are shifted + * into display time by `offsetMinutes` and compared with the wall-clock + * `nowMs` (the display form of the EPG offset contract). Short-run grouping + * is off: a grid row has no room for group chips. + * + * A programme that started the previous day and is still airing keeps a + * negative `leftPx` (it is not re-clamped to the axis start) — the caller's + * lane must clip with `overflow: hidden` rather than relying on layout to + * hide the offscreen portion. */ export function buildGuideRowBlocks( programs: readonly EpgProgram[], @@ -118,6 +153,8 @@ export function buildGuideRowBlocks( (block) => block.stopMs > axis.startMs && block.startMs < axis.endMs ); const items = buildTimelineRenderItems(blocks, hourWidthPx / 60, { + minWidthPx: EPG_GUIDE_MIN_BLOCK_WIDTH_PX, + gapPx: EPG_GUIDE_BLOCK_GAP_PX, allowGroup: false, nowMs, archivePlaybackAvailable: options.catchUpAvailable ?? false, diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts index efd80d6b4..e8830acdb 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts @@ -30,6 +30,7 @@ describe('epg-guide-preferences', () => { }); persistEpgGuideDockCollapsed(true); expect(localStorage.getItem(EPG_GUIDE_DENSITY_KEY)).toBe('compact'); + expect(localStorage.getItem(EPG_GUIDE_ZOOM_KEY)).toBe('480'); expect(localStorage.getItem(EPG_GUIDE_ONLY_WITH_EPG_KEY)).toBe('1'); expect(localStorage.getItem(EPG_GUIDE_DOCK_COLLAPSED_KEY)).toBe('1'); expect(restoreEpgGuidePreferences()).toEqual({ @@ -62,6 +63,10 @@ describe('epg-guide-preferences', () => { broken ) ).not.toThrow(); - expect(restoreEpgGuidePreferences(broken).density).toBe('comfortable'); + expect(restoreEpgGuidePreferences(broken)).toEqual({ + density: 'comfortable', + zoom: EPG_GUIDE_ZOOM_DEFAULT, + onlyWithEpg: false, + }); }); }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts index a2c01cf1a..6c743ff6d 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts @@ -52,10 +52,18 @@ export function restoreEpgGuidePreferences( storage: Storage = defaultStorage() ): EpgGuidePreferences { const density = read(storage, EPG_GUIDE_DENSITY_KEY); - const zoom = Number(read(storage, EPG_GUIDE_ZOOM_KEY)); + const rawZoom = read(storage, EPG_GUIDE_ZOOM_KEY); + // `null` (never stored) and `''` (a cleared/corrupt value) both mean "no + // preference" and fall back to the default; anything else — including + // non-numeric garbage, which `Number()` turns into `NaN` — is clamped by + // `clampGuideZoom`, which itself defaults on a non-finite value. + const zoom = + rawZoom === null || rawZoom === '' + ? EPG_GUIDE_ZOOM_DEFAULT + : clampGuideZoom(Number(rawZoom)); return { density: isDensity(density) ? density : 'comfortable', - zoom: clampGuideZoom(zoom === 0 ? Number.NaN : zoom), + zoom, onlyWithEpg: read(storage, EPG_GUIDE_ONLY_WITH_EPG_KEY) === '1', }; } @@ -65,8 +73,16 @@ export function persistEpgGuidePreferences( storage: Storage = defaultStorage() ): void { write(storage, EPG_GUIDE_DENSITY_KEY, preferences.density); - write(storage, EPG_GUIDE_ZOOM_KEY, String(clampGuideZoom(preferences.zoom))); - write(storage, EPG_GUIDE_ONLY_WITH_EPG_KEY, preferences.onlyWithEpg ? '1' : '0'); + write( + storage, + EPG_GUIDE_ZOOM_KEY, + String(clampGuideZoom(preferences.zoom)) + ); + write( + storage, + EPG_GUIDE_ONLY_WITH_EPG_KEY, + preferences.onlyWithEpg ? '1' : '0' + ); } export function restoreEpgGuideDockCollapsed( diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts index e2ddc4000..d954a99fc 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts @@ -26,9 +26,9 @@ export interface EpgGuideScope { /** A request window. Instants are provider-clock ms (display offset removed). */ export interface EpgGuideWindow { - channels: EpgGuideChannel[]; - fromMs: number; - toMs: number; + readonly channels: readonly EpgGuideChannel[]; + readonly fromMs: number; + readonly toMs: number; } export interface EpgGuideCatchUp { @@ -36,6 +36,17 @@ export interface EpgGuideCatchUp { watch(channel: EpgGuideChannel, program: EpgProgram): void; } +/** + * One programme search result. `channelId` is the matching row's + * `EpgGuideChannel.id` when the host can resolve it (e.g. an exact `epgKey` + * match) — `null` when the host cannot say which row the hit belongs to, in + * which case the guide can show the hit but not jump to or highlight a row. + */ +export interface EpgGuideSearchHit { + channelId: string | null; + program: EpgProgram; +} + /** * Everything the guide needs from its host. The host owns scope state, * playback and the player; the guide owns rendering, caching and keyboard @@ -49,14 +60,14 @@ export interface EpgGuideSource { readonly scopeId: Signal; setScope(id: string): void; /** Programmes overlapping the window, keyed by `EpgGuideChannel.id`. */ - loadPrograms(window: EpgGuideWindow): Promise>; + loadPrograms(range: EpgGuideWindow): Promise>; /** Ids of channels with at least one programme in the window. */ - loadCoverage(window: EpgGuideWindow): Promise>; + loadCoverage(range: EpgGuideWindow): Promise>; readonly activeChannelId: Signal; /** Switch playback; the guide stays open. */ activate(channelId: string): void; /** Optional programme search; the toolbar hides its field when absent. */ - searchPrograms?(query: string): Promise; + searchPrograms?(query: string): Promise; readonly catchUp?: EpgGuideCatchUp; }