fix(dashboard): refresh corrected guides and pause expiry timers while hidden

Review follow-ups:

- A programme still on air was not asked for again until it ended, so a
  guide import that corrected it could leave the dashboard wrong for
  hours. Re-ask at once on EpgService.epgAvailable$ (guide import or
  source change), and at least every five minutes
  (LIVE_EPG_MAX_ANSWER_AGE_MS) for changes that arrive by other paths.
- The source-expiry clock scheduled from its last tick, so facts that
  arrived long after it could be scheduled up to an hour late. Schedule
  from Date.now().
- The expiry timer stayed armed while the page was hidden. Arm none until
  the page is visible again; becoming visible moves the clock at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 20:48:56 +02:00
1 parent 81f78dea8c
commit bfb89b0910
6 files changed
+169 -33

No files matched your search

@@ -1,6 +1,6 @@
import { computed, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { EMPTY, of, throwError } from 'rxjs';
import { BehaviorSubject, EMPTY, of, throwError } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import {
DEFAULT_DASHBOARD_RAILS_SETTINGS,
@@ -42,6 +42,7 @@ const program = (title: string): EpgProgram =>
describe('DashboardLiveEpgPresenter', () => {
let presenter: DashboardLiveEpgPresenter;
let getCurrentProgramsForChannels: jest.Mock;
let epgAvailable: BehaviorSubject<boolean>;
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
let recentItems: ReturnType<typeof signal<PortalActivityItem[]>>;
let favoriteLiveItems: ReturnType<typeof signal<PortalActivityItem[]>>;
@@ -69,6 +70,7 @@ describe('DashboardLiveEpgPresenter', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z'));
getCurrentProgramsForChannels = jest.fn(() => of(new Map()));
epgAvailable = new BehaviorSubject(false);
playlists = signal<PlaylistMeta[]>([
m3uPlaylist('a', [guideA]),
m3uPlaylist('a2', [guideA]),
@@ -108,7 +110,10 @@ describe('DashboardLiveEpgPresenter', () => {
},
{
provide: EpgService,
useValue: { getCurrentProgramsForChannels },
useValue: {
getCurrentProgramsForChannels,
epgAvailable$: epgAvailable,
},
},
{
provide: SettingsStore,
@@ -347,7 +352,66 @@ describe('DashboardLiveEpgPresenter', () => {
);
});
it('asks a guide again only once a programme on air has ended', () => {
/** One clock tick: the interval fires, then effects flush. */
const tick = (count = 1) => {
for (let index = 0; index < count; index++) {
jest.advanceTimersByTime(30_000);
TestBed.tick();
}
};
it('asks a guide again once a programme on air has ended', () => {
const fromA = card({
id: 'a',
epgLookupKey: 'ard.de',
epgPlaylistId: 'a',
});
getCurrentProgramsForChannels.mockImplementation(() =>
of(
new Map([
[
'ard.de',
{
...program('Short'),
stop: '2026-05-23T10:32:00.000Z',
},
],
])
)
);
setup([fromA]);
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1);
// 10:30 → 10:31:30: still on air.
tick(3);
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1);
// The 10:32 tick sees it ended and asks again.
tick();
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2);
});
it('asks again at least every five minutes while a programme is on air', () => {
const fromA = card({
id: 'a',
epgLookupKey: 'ard.de',
epgPlaylistId: 'a',
});
getCurrentProgramsForChannels.mockImplementation(() =>
of(new Map([['ard.de', program('Tagesschau')]]))
);
setup([fromA]);
// 10:30 → 10:34:30: the 10:00–11:00 programme is on air and fresh.
tick(9);
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1);
// 10:35: the answer is five minutes old; a guide may have changed.
tick();
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2);
});
it('asks again at once when a guide import or source change lands', () => {
const fromA = card({
id: 'a',
epgLookupKey: 'ard.de',
@@ -359,18 +423,14 @@ describe('DashboardLiveEpgPresenter', () => {
setup([fromA]);
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1);
// 10:30 → 10:59:30, one tick at a time: the 10:00–11:00 programme is
// still on air on every one of them.
for (let tick = 0; tick < 59; tick++) {
jest.advanceTimersByTime(30_000);
TestBed.tick();
}
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1);
// The 11:00 tick sees it ended and asks again.
jest.advanceTimersByTime(30_000);
getCurrentProgramsForChannels.mockImplementation(() =>
of(new Map([['ard.de', program('Corrected')]]))
);
epgAvailable.next(true);
TestBed.tick();
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2);
expect(presenter.detailsFor(fromA)?.nowPlayingTitle).toBe('Corrected');
});
it('moves progress on every clock tick while the programme is unchanged', () => {
@@ -16,7 +16,9 @@ import {
filter,
forkJoin,
map,
merge,
of,
skip,
switchMap,
tap,
} from 'rxjs';
@@ -44,6 +46,7 @@ import {
liveEpgAnswersNeedRefresh,
liveEpgProgramKey,
liveEpgScopeKey,
LIVE_EPG_MAX_ANSWER_AGE_MS,
sameLiveEpgAnswers,
type DashboardLiveEpgDetails,
type DashboardLiveEpgLookupGroup,
@@ -152,11 +155,18 @@ export class DashboardLiveEpgPresenter {
/** Created once: `toObservable` owns an effect for the injector's life. */
private readonly now$ = toObservable(this.clock.now);
// Asked on rail or offset change, then on clock ticks only once an
// answer can be stale: a programme ended, or a key is still without
// one. A programme that is still on air is not asked for again, and an
// unchanged answer is not re-emitted, so the rails rebuild on a tick
// only for the progress bars.
/** A guide import or source change can replace a programme on air. */
private readonly guideChanged$ = this.epgService.epgAvailable$.pipe(
skip(1),
filter(Boolean),
map(() => Date.now())
);
// Asked on rail or offset change and whenever the guide changes. On
// clock ticks it is asked again only once an answer can be stale: a
// programme ended, a key is still without one, or the answer is older
// than LIVE_EPG_MAX_ANSWER_AGE_MS. An unchanged answer is not
// re-emitted, so the rails rebuild on a tick only for the progress bars.
private readonly programs = toSignal(
combineLatest([
toObservable(this.lookupGroups),
@@ -168,20 +178,29 @@ export class DashboardLiveEpgPresenter {
}
let answers: ReadonlyMap<string, EpgProgram | null> | null =
null;
return this.now$.pipe(
filter((nowMs) =>
liveEpgAnswersNeedRefresh(
answers,
groups,
epgProviderClockMs(nowMs, offsetMinutes)
)
let answeredAt = 0;
return merge(
this.now$,
this.guideChanged$.pipe(tap(() => (answers = null)))
).pipe(
filter(
(nowMs) =>
nowMs - answeredAt >= LIVE_EPG_MAX_ANSWER_AGE_MS ||
liveEpgAnswersNeedRefresh(
answers,
groups,
epgProviderClockMs(nowMs, offsetMinutes)
)
),
switchMap(() =>
forkJoin(
groups.map((group) => this.askScope(group))
).pipe(map((scopes) => mergeAnswers(scopes)))
),
tap((merged) => (answers = merged)),
tap((merged) => {
answers = merged;
answeredAt = Date.now();
}),
distinctUntilChanged(sameLiveEpgAnswers)
);
})
@@ -11,6 +11,11 @@ import type { DashboardRailCard } from './dashboard-rail.component';
// the SQLite backend with a batched IPC every animation frame.
export const LIVE_EPG_TICK_MS = 30_000;
// A programme still on air is asked for again at least this often: a guide
// refreshed outside this page's view can correct or replace it, and nothing
// else tells the dashboard.
export const LIVE_EPG_MAX_ANSWER_AGE_MS = 5 * 60_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. `startTimestamp`/`stopTimestamp` are unix SECONDS
@@ -20,9 +20,16 @@ describe('createSourceExpiryClock', () => {
reportedExpired: false,
});
let hidden: boolean;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(nowMs);
hidden = false;
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => hidden,
});
facts = signal<ReadonlyMap<string, SourceExpiryFacts>>(new Map());
clock = TestBed.runInInjectionContext(() =>
createSourceExpiryClock(facts)
@@ -32,6 +39,7 @@ describe('createSourceExpiryClock', () => {
afterEach(() => {
TestBed.resetTestingModule();
delete (document as { hidden?: boolean }).hidden;
jest.useRealTimers();
});
@@ -75,4 +83,34 @@ describe('createSourceExpiryClock', () => {
expect(clock()).toBe(before + SOURCE_EXPIRY_MAX_WAIT_MS);
});
it('schedules from the real time when facts arrive long after the last tick', () => {
// No facts yet, so no timer: the clock stays at its start value.
jest.advanceTimersByTime(50 * 60_000);
const before = clock();
// The boundary is 5 minutes from the real time, 55 from the clock.
facts.set(new Map([['xtream', expiringIn(2 * DAY_MS + 55 * 60_000)]]));
TestBed.tick();
jest.advanceTimersByTime(6 * 60_000);
TestBed.tick();
expect(clock()).toBeGreaterThan(before);
});
it('arms no timer while hidden and catches up as the page returns', () => {
facts.set(new Map([['xtream', expiringIn(2 * DAY_MS + 20 * 60_000)]]));
hidden = true;
document.dispatchEvent(new Event('visibilitychange'));
TestBed.tick();
expect(jest.getTimerCount()).toBe(0);
jest.advanceTimersByTime(3 * 60 * 60_000);
hidden = false;
document.dispatchEvent(new Event('visibilitychange'));
TestBed.tick();
expect(clock()).toBe(Date.now());
expect(jest.getTimerCount()).toBe(1);
});
});
@@ -15,8 +15,9 @@ const BOUNDARY_SLACK_MS = 1_000;
* Badges move at day granularity, so instead of polling the clock this arms
* one timer for the earliest badge boundary among the known facts (none at
* all when no badge can change), capped at {@link SOURCE_EXPIRY_MAX_WAIT_MS}
* because timers do not follow system sleep or clock changes. Becoming
* visible re-reads the clock at once for the same reason. Must be created in
* because timers do not follow system sleep or clock changes. No timer is
* armed while the document is hidden; becoming visible re-reads the clock
* at once. Must be created in
* an injection context.
*/
export function createSourceExpiryClock(
@@ -24,9 +25,16 @@ export function createSourceExpiryClock(
): Signal<number> {
const document = inject(DOCUMENT);
const now = signal(Date.now());
const visible = signal(!document.hidden);
effect((onCleanup) => {
const nowMs = now();
// Read for the dependency; schedule from the real time, because the
// facts can change long after the clock last moved.
now();
// Hidden: no badge is on screen. Becoming visible moves the clock,
// which re-runs this effect.
if (!visible()) return;
const nowMs = Date.now();
let next: number | null = null;
for (const entry of facts().values()) {
const change = nextSourceExpiryChangeMs(entry, nowMs);
@@ -45,6 +53,7 @@ export function createSourceExpiryClock(
});
const onVisibilityChange = () => {
visible.set(!document.hidden);
if (!document.hidden) now.set(Date.now());
};
document.addEventListener('visibilitychange', onVisibilityChange);