diff --git a/.changes/dashboard-live-epg-timestamp-units.md b/.changes/dashboard-live-epg-timestamp-units.md new file mode 100644 index 000000000..a5b267810 --- /dev/null +++ b/.changes/dashboard-live-epg-timestamp-units.md @@ -0,0 +1,9 @@ +--- +type: internal +area: dashboard +--- + +The dashboard's live "now playing" time range and progress bar now read +pre-computed EPG timestamps as unix seconds, matching the rest of the EPG +code. Today's dashboard lookups never carry those fields, so nothing changes +on screen; this closes the gap before a future data path supplies them. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts index 27a8c9b43..04e9d039b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts @@ -1,44 +1,146 @@ import type { EpgProgram } from '@iptvnator/shared/interfaces'; import { buildDashboardLiveEpgDetails, + calcEpgProgress, formatEpgTimeRange, } from './dashboard-live-epg.utils'; -const HOUR_MS = 60 * 60_000; -// Local 12:00 so the HH:mm expectations below are timezone-independent. -const START = new Date(2026, 5, 28, 12, 0, 0, 0).getTime(); -const NOW = START + HOUR_MS / 2; // 12:30 -const program: EpgProgram = { - start: new Date(START).toISOString(), - stop: new Date(START + HOUR_MS).toISOString(), - channel: 'ch', - title: 'Noon Show', - desc: null, - category: null, -}; +// Local-time wall clock so the formatted range is stable in every timezone +// the suite runs in. +const START = new Date(2026, 0, 15, 20, 0, 0, 0); +const STOP = new Date(2026, 0, 15, 21, 0, 0, 0); +const MIDPOINT_MS = START.getTime() + (STOP.getTime() - START.getTime()) / 2; + +function program(overrides: Partial = {}): EpgProgram { + return { + start: START.toISOString(), + stop: STOP.toISOString(), + channel: 'channel-1', + title: 'Evening News', + desc: null, + category: null, + ...overrides, + }; +} describe('dashboard-live-epg.utils', () => { - it('formats the range and progress in wall-clock terms by default', () => { - expect(formatEpgTimeRange(program)).toBe('12:00 – 13:00'); - expect(buildDashboardLiveEpgDetails(program, NOW)).toEqual({ - nowPlayingTitle: 'Noon Show', - nowPlayingTimeRange: '12:00 – 13:00', - nowPlayingProgress: 50, + describe('timestamps in unix seconds', () => { + // `EpgProgram.startTimestamp`/`stopTimestamp` are unix SECONDS + // everywhere else in the app; treating them as milliseconds put + // every programme in January 1970 and broke the progress bar. + it('scales startTimestamp/stopTimestamp to milliseconds', () => { + const row = program({ + // Deliberately different ISO strings prove the cached + // timestamps win over the strings when both are present. + start: '2000-01-01T00:00:00.000Z', + stop: '2000-01-01T01:00:00.000Z', + startTimestamp: Math.floor(START.getTime() / 1000), + stopTimestamp: Math.floor(STOP.getTime() / 1000), + }); + + expect(formatEpgTimeRange(row)).toBe('20:00 – 21:00'); + expect(calcEpgProgress(row, MIDPOINT_MS)).toBe(50); + expect(buildDashboardLiveEpgDetails(row, MIDPOINT_MS)).toEqual({ + nowPlayingTitle: 'Evening News', + nowPlayingTimeRange: '20:00 – 21:00', + nowPlayingProgress: 50, + }); + }); + + it('falls back to the ISO strings for zero or non-finite timestamps', () => { + const row = program({ + startTimestamp: 0, + stopTimestamp: Number.NaN, + }); + + expect(formatEpgTimeRange(row)).toBe('20:00 – 21:00'); + expect(calcEpgProgress(row, MIDPOINT_MS)).toBe(50); }); }); - it('shifts the label and measures progress in the provider clock with a display offset', () => { - // Offset +60: the guide runs an hour ahead, so the 12:00 row really - // airs 13:00–14:00 and at 12:30 has not started yet. - expect(formatEpgTimeRange(program, 60)).toBe('13:00 – 14:00'); - expect(buildDashboardLiveEpgDetails(program, NOW, 60)).toEqual({ - nowPlayingTitle: 'Noon Show', - nowPlayingTimeRange: '13:00 – 14:00', - nowPlayingProgress: 0, + describe('ISO fallback', () => { + it('reads the ISO strings when no timestamps are present', () => { + const row = program(); + + expect(formatEpgTimeRange(row)).toBe('20:00 – 21:00'); + expect(calcEpgProgress(row, MIDPOINT_MS)).toBe(50); + }); + + it('clamps progress to the 0–100 range', () => { + const row = program(); + + expect(calcEpgProgress(row, START.getTime() - 60_000)).toBe(0); + expect(calcEpgProgress(row, STOP.getTime() + 60_000)).toBe(100); + }); + + it('returns null when the range cannot be resolved', () => { + const unparseable = program({ start: 'not-a-date' }); + const inverted = program({ + start: STOP.toISOString(), + stop: START.toISOString(), + }); + + expect(formatEpgTimeRange(unparseable)).toBeNull(); + expect(calcEpgProgress(unparseable, MIDPOINT_MS)).toBeNull(); + expect(calcEpgProgress(inverted, MIDPOINT_MS)).toBeNull(); + }); + }); + + describe('EPG display offset', () => { + it('formats the range and progress in wall-clock terms by default', () => { + expect(formatEpgTimeRange(program())).toBe('20:00 – 21:00'); + expect( + buildDashboardLiveEpgDetails(program(), MIDPOINT_MS) + ).toEqual({ + nowPlayingTitle: 'Evening News', + nowPlayingTimeRange: '20:00 – 21:00', + nowPlayingProgress: 50, + }); + }); + + it('shifts the label and measures progress in the provider clock with a display offset', () => { + // Offset +60: the guide runs an hour ahead, so the 20:00 row + // really airs 21:00–22:00 and at 20:30 has not started yet. + expect(formatEpgTimeRange(program(), 60)).toBe('21:00 – 22:00'); + expect( + buildDashboardLiveEpgDetails(program(), MIDPOINT_MS, 60) + ).toEqual({ + nowPlayingTitle: 'Evening News', + nowPlayingTimeRange: '21:00 – 22:00', + nowPlayingProgress: 0, + }); + // Offset -30: it really started at 19:30 and has finished. + expect( + buildDashboardLiveEpgDetails(program(), MIDPOINT_MS, -30) + ?.nowPlayingProgress + ).toBe(100); + }); + + it('applies the offset to seconds-based timestamps as well', () => { + const row = program({ + start: '2000-01-01T00:00:00.000Z', + stop: '2000-01-01T01:00:00.000Z', + startTimestamp: Math.floor(START.getTime() / 1000), + stopTimestamp: Math.floor(STOP.getTime() / 1000), + }); + + expect(formatEpgTimeRange(row, 60)).toBe('21:00 – 22:00'); + expect( + buildDashboardLiveEpgDetails(row, MIDPOINT_MS, 60) + ?.nowPlayingProgress + ).toBe(0); + }); + }); + + describe('buildDashboardLiveEpgDetails', () => { + it('returns null for a missing programme', () => { + expect(buildDashboardLiveEpgDetails(null, MIDPOINT_MS)).toBeNull(); + }); + + it('returns null when nothing is displayable', () => { + const row = program({ title: ' ', start: '', stop: '' }); + + expect(buildDashboardLiveEpgDetails(row, MIDPOINT_MS)).toBeNull(); }); - // Offset -30: it really started at 11:30 and is two thirds through. - expect( - buildDashboardLiveEpgDetails(program, NOW, -30)?.nowPlayingProgress - ).toBe(100); }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts index 548e46e9e..99d22d4aa 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts @@ -13,15 +13,18 @@ export const LIVE_EPG_TICK_MS = 30_000; // Reads either an ISO `start`/`stop` or the pre-computed `startTimestamp` // when present. The parsed XMLTV pipeline populates both, but legacy rows -// only carry the strings. +// only carry the strings. `startTimestamp`/`stopTimestamp` are unix SECONDS +// (the same contract `getProgramTimeMs` in `@iptvnator/ui/epg` reads), so a +// usable value is scaled to milliseconds; zero, negative or non-finite +// values are treated as absent and fall back to the ISO string. function epgTimestampMs( program: EpgProgram, side: 'start' | 'stop' ): number | null { const cached = side === 'start' ? program.startTimestamp : program.stopTimestamp; - if (cached != null) { - return cached; + if (Number.isFinite(cached) && Number(cached) > 0) { + return Number(cached) * 1000; } const iso = side === 'start' ? program.start : program.stop; const ms = iso ? new Date(iso).getTime() : NaN; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index 0b34f85ac..3d9d265b5 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -189,14 +189,18 @@ describe('resolveDashboardHeroArtwork', () => { }); describe('EPG enrichment helpers', () => { + // `startTimestamp`/`stopTimestamp` are unix SECONDS, as everywhere else + // in the app; the helpers scale them to the millisecond clock they get. + const START_S = Math.floor(Date.UTC(2026, 4, 19, 12, 0, 0) / 1000); + const STOP_S = Math.floor(Date.UTC(2026, 4, 19, 12, 30, 0) / 1000); const baseProgram = (overrides: Partial = {}): EpgProgram => ({ title: 'Tagesschau', desc: null, start: '2026-05-19T12:00:00.000Z', stop: '2026-05-19T12:30:00.000Z', - startTimestamp: Date.UTC(2026, 4, 19, 12, 0, 0), - stopTimestamp: Date.UTC(2026, 4, 19, 12, 30, 0), + startTimestamp: START_S, + stopTimestamp: STOP_S, ...overrides, }) as EpgProgram; @@ -231,8 +235,8 @@ describe('EPG enrichment helpers', () => { describe('calcEpgProgress', () => { const program = baseProgram(); - const start = program.startTimestamp as number; - const stop = program.stopTimestamp as number; + const start = START_S * 1000; + const stop = STOP_S * 1000; it('returns 0 at the start of the window', () => { expect(calcEpgProgress(program, start)).toBe(0); @@ -255,13 +259,13 @@ describe('EPG enrichment helpers', () => { it('returns null when the window is zero-length or inverted', () => { expect( calcEpgProgress( - baseProgram({ stopTimestamp: start }), + baseProgram({ stopTimestamp: START_S }), start + 10 ) ).toBeNull(); expect( calcEpgProgress( - baseProgram({ stopTimestamp: start - 1 }), + baseProgram({ stopTimestamp: START_S - 1 }), start ) ).toBeNull(); @@ -271,8 +275,8 @@ describe('EPG enrichment helpers', () => { describe('buildDashboardLiveEpgDetails', () => { it('builds the current programme details used by the live hero banner', () => { const program = baseProgram({ title: 'Market Open' }); - const start = program.startTimestamp as number; - const stop = program.stopTimestamp as number; + const start = START_S * 1000; + const stop = STOP_S * 1000; const now = start + (stop - start) / 4; expect(buildDashboardLiveEpgDetails(program, now)).toMatchObject({