fix(epg): make guide geometry DST-safe and tighten the contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 12:30:04 +02:00
1 parent 35354c85c9
commit 19aee624f7
5 files changed
+169 -25

No files matched your search

@@ -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', () => {
@@ -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,
@@ -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,
});
});
});
@@ -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(
@@ -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<string>;
setScope(id: string): void;
/** Programmes overlapping the window, keyed by `EpgGuideChannel.id`. */
loadPrograms(window: EpgGuideWindow): Promise<Map<string, EpgProgram[]>>;
loadPrograms(range: EpgGuideWindow): Promise<Map<string, EpgProgram[]>>;
/** Ids of channels with at least one programme in the window. */
loadCoverage(window: EpgGuideWindow): Promise<Set<string>>;
loadCoverage(range: EpgGuideWindow): Promise<Set<string>>;
readonly activeChannelId: Signal<string | null>;
/** Switch playback; the guide stays open. */
activate(channelId: string): void;
/** Optional programme search; the toolbar hides its field when absent. */
searchPrograms?(query: string): Promise<EpgProgram[]>;
searchPrograms?(query: string): Promise<EpgGuideSearchHit[]>;
readonly catchUp?: EpgGuideCatchUp;
}