mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(dashboard): read cached EPG timestamps as unix seconds (#1532)
`epgTimestampMs` in the dashboard live-EPG helpers returned `EpgProgram.startTimestamp`/`stopTimestamp` as-is, but those fields are unix SECONDS everywhere else (`getProgramTimeMs` in `@iptvnator/ui/epg` multiplies by 1000). The bug was latent: the backend `GET_CURRENT_PROGRAMS_BATCH` rows never set the fields, so the ISO fallback always ran. Scale a positive finite timestamp by 1000 and treat zero/non-finite values as absent, mirroring `getProgramTimeMs`. Add a dedicated spec that feeds seconds-based timestamps and asserts the formatted range and progress, and fix the rails component spec fixture, which stored millisecond values in the seconds fields and so encoded the old bug. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb8e997c8f
commit
4d8559976c
4 files changed
+159
-41
No files matched your search
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
+12
-8
@@ -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> = {}): 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({
|
||||
|
||||
Reference in new issue
Block a user