mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user