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

+9 -4
View File
@@ -207,9 +207,12 @@ hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
the XMLTV lookup has cards or the portal presenter wants one, and
only while the document is visible; it reads the clock at once when
it starts again. A tick re-reads progress for every live card. It
re-asks an XMLTV scope only after one of its programmes has ended or
while a key has no programme, and an unchanged answer is not
re-emitted.
re-asks an XMLTV scope only after one of its programmes has ended,
while a key has no programme, or once the answer is five minutes old
(`LIVE_EPG_MAX_ANSWER_AGE_MS`), because a guide refreshed elsewhere
can correct a programme still on air. A guide import or source change
(`EpgService.epgAvailable$`) re-asks at once. An unchanged answer is
not re-emitted.
Xtream and Stalker cards have no XMLTV key of their own; their "now on
air" line comes from the portal, **lazily and per card**:
- `buildDashboardPortalLiveEpgEntry` (dashboard data-access) turns a
@@ -404,5 +407,7 @@ full-playlist read. The chip is not a separate account-refresh request.
The badge only changes at day boundaries, so the rails do not poll the clock:
`createSourceExpiryClock` arms one timer for the earliest boundary among the
known facts (`nextSourceExpiryChangeMs`), capped at an hour because timers do
not follow system sleep, and re-reads the clock when the page becomes visible.
not follow system sleep. It arms no timer while the page is hidden and
re-reads the clock when the page becomes visible. It schedules from the real
time, so facts that arrive long after the last tick are not scheduled late.
Facts whose badge can no longer change arm no timer.
@@ -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);