perf(dashboard): stop idle dashboard ticks that have nothing to update (#1722)

This commit is contained in:
4gray authored and GitHub committed 2026-09-30 18:50:02 +02:00
1 parent 9d3e71a952
commit 97f56e219c
17 files changed
+904 -63

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
type: perf
area: dashboard
---
A dashboard left open does less work: it only refreshes live programmes while
live channels are shown and the window is visible, asks the guide again only
when a programme ends, and no longer snaps a scrolled rail back to the start.
+24 -1
View File
@@ -142,6 +142,10 @@ hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
1. Inputs: `label`, `items: DashboardRailCard[]`, optional `seeAllLink`,
optional `aspectRatio` (default `'2 / 3'`), optional `testId`.
2. Behavior: horizontal flex track with `scroll-snap-type: x mandatory`.
The track scrolls back to the start and re-observes its cards only when
the ids or order of `items` change. Hosts rebuild card objects on every
clock tick (live progress, expiry badges), and such a rebuild must not
move a rail the user scrolled.
3. Chevron buttons fade in on hover (desktop only via `@media (hover: none)`).
Edge fades follow the chevrons' visibility. The track bleeds
`--rail-bleed` past the viewport on every side so card focus rings and
@@ -198,6 +202,17 @@ hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
lookups" in `m3u-playlist-module.md`, forwards everything portal-shaped
to `DashboardPortalLiveEpgPresenter`, and `enrich()` returns the cards
with their "now on air" row filled in.
One component-provided `DashboardLiveEpgClock` drives every live
refresh and progress bar on the page. It ticks every 30 s only while
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,
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
@@ -214,7 +229,8 @@ hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
- `DashboardPortalLiveEpgPresenter` (component-provided) unions the
visible keys of both rails with the pinned hero keys and calls
`DashboardPortalLiveEpgService.sync()` with exactly those entries —
on every change, on the 30 s tick, and on a display-offset change.
on every change, on each tick of the shared live-EPG clock, and on
a display-offset change.
It is reached through `DashboardLiveEpgPresenter`, which derives the
portal rows itself from the enabled rails and pins the hero's live
candidates, so the page component only forwards what a rail can see. The queue lives in
@@ -388,3 +404,10 @@ Xtream expiry from cached `PortalStatusService.checkPortalStatusDetails()`
(`exp_date`). Stalker uses the persisted `stalkerAccountInfo` snapshot from the
playlist payload, not the metadata row; each source therefore needs one memoized
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. It arms no timer while the page is hidden or the
sources rail is disabled, 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.
@@ -5,6 +5,7 @@ import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DashboardSourceExpiryService } from './dashboard-source-expiry.service';
import {
SOURCE_EXPIRY_WARNING_DAYS,
nextSourceExpiryChangeMs,
resolveSourceExpiryBadge,
} from './dashboard-source-expiry.util';
@@ -91,6 +92,61 @@ describe('resolveSourceExpiryBadge', () => {
});
});
describe('nextSourceExpiryChangeMs', () => {
const nowMs = Date.UTC(2026, 7, 1, 12, 0, 0);
const badgeAt = (expiresAtSeconds: number, atMs: number) =>
JSON.stringify(
resolveSourceExpiryBadge(
{ expiresAtSeconds, reportedExpired: false },
atMs
)
);
it.each([
['far outside the warning window', 30 * DAY_SECONDS + 5],
['one day past the warning window', 8 * DAY_SECONDS - 60],
['inside the warning window', 3 * DAY_SECONDS + 3_600],
['on the last day', 7_200],
['exactly on a day boundary', 2 * DAY_SECONDS],
])('returns the first instant the badge changes (%s)', (_, secondsLeft) => {
const expiresAt = nowMs / 1000 + secondsLeft;
const next = nextSourceExpiryChangeMs(
{ expiresAtSeconds: expiresAt, reportedExpired: false },
nowMs
);
expect(next).not.toBeNull();
const boundary = next as number;
expect(boundary).toBeGreaterThan(nowMs);
// Unchanged up to the boundary, different from it on.
expect(badgeAt(expiresAt, boundary - 1)).toBe(badgeAt(expiresAt, nowMs));
expect(badgeAt(expiresAt, boundary)).not.toBe(badgeAt(expiresAt, nowMs));
});
it('never schedules for facts whose badge cannot change any more', () => {
const nowSeconds = nowMs / 1000;
expect(nextSourceExpiryChangeMs(null, nowMs)).toBeNull();
expect(
nextSourceExpiryChangeMs(
{ expiresAtSeconds: null, reportedExpired: true },
nowMs
)
).toBeNull();
expect(
nextSourceExpiryChangeMs(
{ expiresAtSeconds: nowSeconds - 1, reportedExpired: false },
nowMs
)
).toBeNull();
expect(
nextSourceExpiryChangeMs(
{ expiresAtSeconds: 0, reportedExpired: false },
nowMs
)
).toBeNull();
});
});
describe('DashboardSourceExpiryService', () => {
let service: DashboardSourceExpiryService;
let portalStatusService: { checkPortalStatusDetails: jest.Mock };
@@ -2,12 +2,11 @@
export const SOURCE_EXPIRY_WARNING_DAYS = 7;
/**
* How often badge consumers should re-evaluate {@link resolveSourceExpiryBadge}
* against the wall clock. The badge state only moves at day granularity, but a
* dashboard left open must still cross day-countdown and expiration
* boundaries without a remount; a minute tick is imperceptibly cheap.
* Longest wait before badge consumers re-check the wall clock even when no
* badge boundary is due. A timer does not track wall-clock jumps (system
* sleep, a changed clock), so it is re-armed at least this often.
*/
export const SOURCE_EXPIRY_TICK_MS = 60_000;
export const SOURCE_EXPIRY_MAX_WAIT_MS = 60 * 60_000;
const SECONDS_PER_DAY = 86_400;
@@ -55,3 +54,35 @@ export function resolveSourceExpiryBadge(
const daysLeft = Math.ceil(secondsLeft / SECONDS_PER_DAY);
return daysLeft <= warningDays ? { kind: 'expiring', daysLeft } : null;
}
/**
* The next instant (ms) at which {@link resolveSourceExpiryBadge} would answer
* differently for these facts, or null when no boundary lies ahead: a
* portal-reported expiry, or a timestamp already in the past (for as long as
* the system clock only moves forward). The badge moves only at day
* granularity: it appears `warningDays` days before expiry, counts down once
* per day and turns into "expired" at expiry, so a consumer can wait for that
* boundary instead of polling the clock.
*/
export function nextSourceExpiryChangeMs(
facts: SourceExpiryFacts | null | undefined,
nowMs: number,
warningDays: number = SOURCE_EXPIRY_WARNING_DAYS
): number | null {
if (!facts || facts.reportedExpired) {
return null;
}
const expiresAt = facts.expiresAtSeconds;
if (expiresAt === null || expiresAt <= 0) {
return null;
}
const secondsLeft = expiresAt - nowMs / 1000;
if (secondsLeft <= 0) {
return null;
}
const daysLeft = Math.ceil(secondsLeft / SECONDS_PER_DAY);
// The count drops by one each time another whole day has passed; above
// the warning window only the day the badge appears matters.
const nextDaysLeft = Math.min(daysLeft, warningDays + 1) - 1;
return (expiresAt - nextDaysLeft * SECONDS_PER_DAY) * 1000;
}
@@ -0,0 +1,69 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils';
describe('DashboardLiveEpgClock', () => {
let hidden: boolean;
let clock: DashboardLiveEpgClock;
const setHidden = (value: boolean) => {
hidden = value;
document.dispatchEvent(new Event('visibilitychange'));
TestBed.tick();
};
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-23T10:00:00.000Z'));
hidden = false;
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => hidden,
});
TestBed.configureTestingModule({ providers: [DashboardLiveEpgClock] });
clock = TestBed.inject(DashboardLiveEpgClock);
});
afterEach(() => {
TestBed.resetTestingModule();
delete (document as { hidden?: boolean }).hidden;
jest.useRealTimers();
});
it('schedules nothing while no consumer has a live card', () => {
const active = signal(false);
clock.demand(active);
TestBed.tick();
expect(jest.getTimerCount()).toBe(0);
});
it('ticks on the live-EPG period while demanded and stops when the demand ends', () => {
const active = signal(true);
clock.demand(active);
TestBed.tick();
const started = clock.now();
jest.advanceTimersByTime(LIVE_EPG_TICK_MS);
expect(clock.now()).toBe(started + LIVE_EPG_TICK_MS);
active.set(false);
TestBed.tick();
expect(jest.getTimerCount()).toBe(0);
});
it('pauses while the document is hidden and catches up as it returns', () => {
clock.demand(signal(true));
TestBed.tick();
setHidden(true);
expect(jest.getTimerCount()).toBe(0);
jest.advanceTimersByTime(5 * 60_000);
setHidden(false);
expect(clock.now()).toBe(Date.now());
expect(jest.getTimerCount()).toBe(1);
});
});
@@ -0,0 +1,76 @@
import {
computed,
DestroyRef,
effect,
inject,
Injectable,
signal,
untracked,
type Signal,
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils';
/**
* The dashboard's live-EPG wall clock: one heartbeat shared by the XMLTV
* batch, the portal queue and every progress bar on the page.
*
* It only runs while a consumer has a live card to keep current and while
* the document is visible. A dashboard without live cards, or one behind a
* hidden tab or window, therefore schedules nothing. Starting again reads
* the clock at once, so progress and "now on air" catch up the moment the
* page is back in view instead of up to one period later.
*
* Component-provided, so the heartbeat dies with the dashboard.
*/
@Injectable()
export class DashboardLiveEpgClock {
private readonly document = inject(DOCUMENT);
private readonly demands = signal<readonly Signal<boolean>[]>([]);
private readonly visible = signal(!this.document.hidden);
private readonly nowMs = signal(Date.now());
private readonly running = computed(
() => this.visible() && this.demands().some((demand) => demand())
);
private timer: ReturnType<typeof setInterval> | null = null;
/** Wall-clock ms of the latest tick. */
readonly now: Signal<number> = this.nowMs.asReadonly();
constructor() {
const onVisibilityChange = () =>
this.visible.set(!this.document.hidden);
this.document.addEventListener('visibilitychange', onVisibilityChange);
effect(() => {
const running = this.running();
untracked(() => (running ? this.start() : this.stop()));
});
inject(DestroyRef).onDestroy(() => {
this.stop();
this.document.removeEventListener(
'visibilitychange',
onVisibilityChange
);
});
}
/** Keeps the clock running for as long as `active` reads true. */
demand(active: Signal<boolean>): void {
this.demands.update((demands) => [...demands, active]);
}
private start(): void {
if (this.timer !== null) return;
this.nowMs.set(Date.now());
this.timer = setInterval(
() => this.nowMs.set(Date.now()),
LIVE_EPG_TICK_MS
);
}
private stop(): void {
if (this.timer === null) return;
clearInterval(this.timer);
this.timer = null;
}
}
@@ -1,6 +1,6 @@
import { signal } from '@angular/core';
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,
@@ -12,6 +12,7 @@ import { SettingsStore } from '@iptvnator/services';
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
import type { DashboardRailCard } from './dashboard-rail.component';
const guideA = 'https://a.example/guide.xml';
@@ -41,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[]>>;
@@ -68,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]),
@@ -89,6 +92,7 @@ describe('DashboardLiveEpgPresenter', () => {
TestBed.configureTestingModule({
providers: [
DashboardLiveEpgClock,
DashboardLiveEpgPresenter,
{
provide: DashboardPortalLiveEpgPresenter,
@@ -106,7 +110,10 @@ describe('DashboardLiveEpgPresenter', () => {
},
{
provide: EpgService,
useValue: { getCurrentProgramsForChannels },
useValue: {
getCurrentProgramsForChannels,
epgAvailable$: epgAvailable,
},
},
{
provide: SettingsStore,
@@ -345,6 +352,108 @@ describe('DashboardLiveEpgPresenter', () => {
);
});
/** 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',
epgPlaylistId: 'a',
});
getCurrentProgramsForChannels.mockImplementation(() =>
of(new Map([['ard.de', program('Tagesschau')]]))
);
setup([fromA]);
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1);
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', () => {
const fromA = card({
id: 'a',
epgLookupKey: 'ard.de',
epgPlaylistId: 'a',
});
getCurrentProgramsForChannels.mockImplementation(() =>
of(new Map([['ard.de', program('Tagesschau')]]))
);
setup([fromA]);
const progress = TestBed.runInInjectionContext(() =>
computed(() => presenter.detailsFor(fromA)?.nowPlayingProgress)
);
expect(progress()).toBe(50);
jest.advanceTimersByTime(6 * 60_000);
TestBed.tick();
expect(progress()).toBe(60);
});
it('keeps the other guides when one lookup is retired or fails mid-tick', () => {
const fromA = card({
id: 'a',
@@ -384,7 +493,13 @@ describe('DashboardLiveEpgPresenter', () => {
)
: throwError(() => new Error('lookup failed'))
);
const callsBeforeTick = getCurrentProgramsForChannels.mock.calls.length;
jest.advanceTimersByTime(30_000);
TestBed.tick();
// Guide B never answered, so the tick asks both guides again.
expect(getCurrentProgramsForChannels.mock.calls.length).toBe(
callsBeforeTick + 2
);
expect(presenter.detailsFor(fromA)?.nowPlayingTitle).toBe(
'Guide A bulletin'
@@ -10,16 +10,21 @@ import {
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import {
catchError,
combineLatest,
defaultIfEmpty,
distinctUntilChanged,
filter,
forkJoin,
interval,
map,
merge,
of,
startWith,
skip,
switchMap,
tap,
} from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import {
epgProviderClockMs,
normalizeDashboardRailsSettings,
type EpgProgram,
type PortalActivityItem,
@@ -31,15 +36,18 @@ import {
DashboardDataService,
} from '@iptvnator/workspace/dashboard/data-access';
import type { DashboardRailCard } from './dashboard-rail.component';
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
import {
buildDashboardLiveEpgDetails,
buildLiveEpgLookupGroups,
getLiveEpgProgramForCard,
liveEpgAllowsAnySource,
liveEpgAnswersNeedRefresh,
liveEpgProgramKey,
liveEpgScopeKey,
LIVE_EPG_TICK_MS,
LIVE_EPG_MAX_ANSWER_AGE_MS,
sameLiveEpgAnswers,
type DashboardLiveEpgDetails,
type DashboardLiveEpgLookupGroup,
} from './dashboard-live-epg.utils';
@@ -88,6 +96,7 @@ export class DashboardLiveEpgPresenter {
private readonly settingsStore = inject(SettingsStore);
/** Xtream/Stalker cards are answered by their portal, not by XMLTV. */
private readonly portal = inject(DashboardPortalLiveEpgPresenter);
private readonly clock = inject(DashboardLiveEpgClock);
private readonly cards = signal<Signal<
readonly DashboardRailCard[]
@@ -140,22 +149,61 @@ export class DashboardLiveEpgPresenter {
)
);
// Re-fetch on rail change AND on a 30s heartbeat so the progress bar
// catches the boundary between programs without a full page revisit.
private readonly offsetMinutes = computed(() =>
this.settingsStore.resolvedEpgOffsetMinutes()
);
/** Created once: `toObservable` owns an effect for the injector's life. */
private readonly now$ = toObservable(this.clock.now);
/** 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(
toObservable(this.lookupGroups).pipe(
switchMap((groups) =>
groups.length === 0
? of(new Map<string, EpgProgram | null>())
: interval(LIVE_EPG_TICK_MS).pipe(
startWith(0),
switchMap(() =>
forkJoin(
groups.map((group) => this.askScope(group))
).pipe(map((answers) => mergeAnswers(answers)))
)
)
)
combineLatest([
toObservable(this.lookupGroups),
toObservable(this.offsetMinutes),
]).pipe(
switchMap(([groups, offsetMinutes]) => {
if (groups.length === 0) {
return of(new Map<string, EpgProgram | null>());
}
let answers: ReadonlyMap<string, EpgProgram | null> | null =
null;
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;
answeredAt = Date.now();
}),
distinctUntilChanged(sameLiveEpgAnswers)
);
})
),
{ initialValue: new Map<string, EpgProgram | null>() }
);
@@ -183,6 +231,7 @@ export class DashboardLiveEpgPresenter {
);
constructor() {
this.clock.demand(computed(() => this.lookupGroups().length > 0));
this.portal.connect(this.portalItems);
// The hero sits at the top of the page and is never scrolled into
// view, so its candidates are wanted regardless of what the rails
@@ -246,8 +295,9 @@ export class DashboardLiveEpgPresenter {
liveEpgAllowsAnySource(card)
)
);
// Recompute the now-window each tick so progress moves between
// 30s ticks even if the program identity is unchanged.
// Read the clock so progress moves on every tick even while the
// programme itself is unchanged.
this.clock.now();
return buildDashboardLiveEpgDetails(
program,
Date.now(),
@@ -3,6 +3,7 @@ import {
buildDashboardLiveEpgDetails,
calcEpgProgress,
formatEpgTimeRange,
sameLiveEpgAnswers,
} from './dashboard-live-epg.utils';
// Local-time wall clock so the formatted range is stable in every timezone
@@ -143,4 +144,35 @@ describe('dashboard-live-epg.utils', () => {
expect(buildDashboardLiveEpgDetails(row, MIDPOINT_MS)).toBeNull();
});
});
describe('sameLiveEpgAnswers', () => {
const answers = (row: EpgProgram | null) =>
new Map<string, EpgProgram | null>([['key', row]]);
it('treats fresh objects with the same rendered fields as unchanged', () => {
expect(
sameLiveEpgAnswers(answers(program()), answers(program()))
).toBe(true);
});
it.each([
['title', { title: 'Late News' }],
['description', { desc: 'Corrected summary' }],
['category', { category: 'Sport' }],
['stop', { stop: new Date(2026, 0, 15, 21, 30).toISOString() }],
])('sees a guide correction to the %s', (_, change) => {
expect(
sameLiveEpgAnswers(
answers(program()),
answers(program(change as Partial<EpgProgram>))
)
).toBe(false);
});
it('sees a programme appear or disappear', () => {
expect(sameLiveEpgAnswers(answers(null), answers(program()))).toBe(
false
);
});
});
});
@@ -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
@@ -250,3 +255,60 @@ export function getLiveEpgProgramForCard(
? (epgMap.get(liveEpgProgramKey(scopeKey, titleKey)) ?? null)
: null;
}
/**
* Whether the XMLTV answers for these groups can have gone stale by
* `providerClockMs` (the raw EPG clock, see `epgProviderClockMs`). A known
* programme stays correct until it ends, so a lookup is only repeated once
* one of them has ended, or while a key is still unanswered or answered
* with nothing on air: a guide imported meanwhile may know it now.
*/
export function liveEpgAnswersNeedRefresh(
answers: ReadonlyMap<string, EpgProgram | null> | null,
groups: readonly DashboardLiveEpgLookupGroup[],
providerClockMs: number
): boolean {
if (!answers) return true;
for (const group of groups) {
for (const lookupKey of group.lookupKeys) {
const program = answers.get(
liveEpgProgramKey(group.scopeKey, lookupKey)
);
if (!program) return true;
const stop = epgTimestampMs(program, 'stop');
if (stop === null || stop <= providerClockMs) return true;
}
}
return false;
}
/**
* Same keys answered with the same programmes: everything the dashboard
* renders from one (title, times, description, category) must match, or a
* guide correction to any of them would be dropped as "unchanged".
*/
export function sameLiveEpgAnswers(
a: ReadonlyMap<string, EpgProgram | null>,
b: ReadonlyMap<string, EpgProgram | null>
): boolean {
if (a === b) return true;
if (a.size !== b.size) return false;
for (const [key, program] of a) {
if (!b.has(key)) return false;
const other = b.get(key) ?? null;
if (program === other) continue;
if (
!program ||
!other ||
program.title !== other.title ||
program.desc !== other.desc ||
program.category !== other.category ||
epgTimestampMs(program, 'start') !==
epgTimestampMs(other, 'start') ||
epgTimestampMs(program, 'stop') !== epgTimestampMs(other, 'stop')
) {
return false;
}
}
return true;
}
@@ -9,6 +9,7 @@ import {
DashboardPortalLiveEpgService,
type DashboardPortalLiveEpgEntry,
} from '@iptvnator/workspace/dashboard/data-access';
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
const xtreamLive = (id: number, playlist = 'p'): PortalActivityItem =>
@@ -44,6 +45,7 @@ describe('DashboardPortalLiveEpgPresenter', () => {
offsetMinutes = signal(0);
TestBed.configureTestingModule({
providers: [
DashboardLiveEpgClock,
DashboardPortalLiveEpgPresenter,
{
provide: DashboardPortalLiveEpgService,
@@ -101,6 +103,23 @@ describe('DashboardPortalLiveEpgPresenter', () => {
expect(wantedKeys().at(-1)).toEqual(['xtream::p::1', 'xtream::p::3']);
});
it('keeps the live-EPG clock stopped while no portal card is wanted', () => {
jest.useFakeTimers();
try {
presenter.connect(signal([xtreamLive(1)]));
TestBed.tick();
expect(jest.getTimerCount()).toBe(0);
presenter.setVisibleCards('recent', [
{ id: 'r1', liveEpgSourceKey: 'xtream::p::1' },
] as never);
TestBed.tick();
expect(jest.getTimerCount()).toBe(1);
} finally {
jest.useRealTimers();
}
});
it('ignores visible keys whose item is no longer on the dashboard', () => {
const items = signal<readonly PortalActivityItem[]>([xtreamLive(1)]);
presenter.connect(items);
@@ -8,8 +8,6 @@ import {
untracked,
type Signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval, map } from 'rxjs';
import type {
EpgProgram,
PortalActivityItem,
@@ -21,7 +19,7 @@ import {
type DashboardPortalLiveEpgEntry,
} from '@iptvnator/workspace/dashboard/data-access';
import type { DashboardRailCard } from './dashboard-rail.component';
import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils';
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
/**
* The rails component's view of `DashboardPortalLiveEpgService`: which
@@ -30,14 +28,16 @@ import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils';
*
* "On screen" is what the rails report through their visibility output,
* plus the pinned keys (the hero card, always at the top). A card the user
* never scrolls to is never asked for. The 30 s tick re-syncs so a programme
* that ended is asked again and progress bars keep moving; a changed display
* offset re-syncs at once because every cached answer was just retired.
* never scrolls to is never asked for. The shared live-EPG clock re-syncs so
* a programme that ended is asked again; it only runs while some card is
* wanted. A changed display offset re-syncs at once because every cached
* answer was just retired.
*/
@Injectable()
export class DashboardPortalLiveEpgPresenter {
private readonly service = inject(DashboardPortalLiveEpgService);
private readonly settingsStore = inject(SettingsStore);
private readonly clock = inject(DashboardLiveEpgClock);
private readonly source = signal<Signal<
readonly PortalActivityItem[]
@@ -47,13 +47,6 @@ export class DashboardPortalLiveEpgPresenter {
>(new Map());
private readonly pinnedKeys = signal<ReadonlySet<string>>(new Set());
/** Heartbeat shared with the XMLTV batch: shifted by one so the first
* emission differs from `initialValue` and is not swallowed. */
readonly tick = toSignal(
interval(LIVE_EPG_TICK_MS).pipe(map((tick) => tick + 1)),
{ initialValue: 0 }
);
private readonly entries = computed<
ReadonlyMap<string, DashboardPortalLiveEpgEntry>
>(() => {
@@ -83,9 +76,10 @@ export class DashboardPortalLiveEpgPresenter {
});
constructor() {
this.clock.demand(computed(() => this.wanted().length > 0));
effect(() => {
const wanted = this.wanted();
this.tick();
this.clock.now();
this.settingsStore.resolvedEpgOffsetMinutes();
untracked(() => this.service.sync(wanted));
});
@@ -335,6 +335,46 @@ describe('DashboardRailComponent', () => {
expect(visible.at(-1)).toEqual(['a', 'b']);
});
it('scrolls back and re-observes only when the card set changes, not on every rebuild', async () => {
installObservers(true);
const { fixture } = await render([
card({ id: 'a', contentType: 'live', nowPlayingProgress: 10 }),
card({ id: 'b', contentType: 'live', nowPlayingProgress: 20 }),
]);
const reset = jest.spyOn(
fixture.componentInstance as unknown as {
scheduleResetToStart: () => void;
},
'scheduleResetToStart'
);
const observedAfterFirstRender = observers[0].observed.length;
const rerender = async (items: DashboardRailCard[]) => {
fixture.componentRef.setInput('items', items);
fixture.detectChanges();
await fixture.whenStable();
};
// A clock tick rebuilds every card object with new progress.
await rerender([
card({ id: 'a', contentType: 'live', nowPlayingProgress: 11 }),
card({ id: 'b', contentType: 'live', nowPlayingProgress: 21 }),
]);
expect(reset).not.toHaveBeenCalled();
expect(observers[0].observed).toHaveLength(
observedAfterFirstRender
);
// A newly watched channel moves to the front.
await rerender([
card({ id: 'b', contentType: 'live' }),
card({ id: 'a', contentType: 'live' }),
]);
expect(reset).toHaveBeenCalledTimes(1);
expect(observers[0].observed.length).toBeGreaterThan(
observedAfterFirstRender
);
});
it('positions the live progress fill through a custom property, never its width', async () => {
// Animating width re-lays out the page for every frame of the
// transition on each EPG tick; the stylesheet slides the fill with
@@ -2,6 +2,7 @@ import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
OnDestroy,
effect,
@@ -172,18 +173,28 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
private resetFrameId: number | null = null;
private settleFrameId: number | null = null;
/**
* Which cards the rail shows, in order. Hosts rebuild their card objects
* on every clock tick (live progress, expiry badges); only a change of
* this identity is a new rail worth scrolling back to the start for or
* re-observing.
*/
private readonly cardIds = computed(() =>
JSON.stringify(this.items().map((card) => card.id))
);
constructor() {
effect(() => {
this.items();
this.cardIds();
if (!this.viewReady()) return;
this.scheduleResetToStart();
});
// The rendered card set changed: watch the new elements. Reading
// `items()` too keeps an id-only change (same elements, new cards)
// from leaving a stale visible set behind.
// The rendered card set changed: watch the new elements. Reading the
// ids too keeps an id-only change (same elements, new cards) from
// leaving a stale visible set behind.
effect(() => {
const elements = this.cardElements();
this.items();
this.cardIds();
untracked(() => this.observeCards(elements));
});
}
@@ -0,0 +1,157 @@
import { signal, type Signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
SOURCE_EXPIRY_MAX_WAIT_MS,
type SourceExpiryFacts,
} from '@iptvnator/workspace/dashboard/data-access';
import { createSourceExpiryClock } from './dashboard-source-expiry-clock';
const DAY_MS = 86_400_000;
describe('createSourceExpiryClock', () => {
const nowMs = Date.UTC(2026, 7, 1, 12, 0, 0);
let facts: ReturnType<
typeof signal<ReadonlyMap<string, SourceExpiryFacts>>
>;
let clock: Signal<number>;
let active: ReturnType<typeof signal<boolean>>;
const expiringIn = (ms: number): SourceExpiryFacts => ({
expiresAtSeconds: (nowMs + ms) / 1000,
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());
active = signal(true);
clock = TestBed.runInInjectionContext(() =>
createSourceExpiryClock(facts, active)
);
TestBed.tick();
});
afterEach(() => {
TestBed.resetTestingModule();
delete (document as { hidden?: boolean }).hidden;
jest.useRealTimers();
});
it('arms no timer when no badge can change', () => {
expect(jest.getTimerCount()).toBe(0);
facts.set(
new Map([
['m3u', { expiresAtSeconds: null, reportedExpired: false }],
['gone', { expiresAtSeconds: null, reportedExpired: true }],
])
);
TestBed.tick();
expect(jest.getTimerCount()).toBe(0);
});
it('moves at the next badge boundary instead of every minute', () => {
// 2 days and 20 minutes left: the countdown drops to "2 days" in 20
// minutes, and nothing needs the clock before that.
facts.set(new Map([['xtream', expiringIn(2 * DAY_MS + 20 * 60_000)]]));
TestBed.tick();
const before = clock();
jest.advanceTimersByTime(19 * 60_000);
TestBed.tick();
expect(clock()).toBe(before);
jest.advanceTimersByTime(2 * 60_000);
TestBed.tick();
expect(clock()).toBe(before + 20 * 60_000 + 1_000);
});
it('re-reads the clock at least hourly while a boundary is far away', () => {
facts.set(new Map([['xtream', expiringIn(30 * DAY_MS)]]));
TestBed.tick();
const before = clock();
jest.advanceTimersByTime(SOURCE_EXPIRY_MAX_WAIT_MS);
TestBed.tick();
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);
});
it('arms no timer while the sources rail is disabled', () => {
active.set(false);
facts.set(new Map([['xtream', expiringIn(2 * DAY_MS + 20 * 60_000)]]));
TestBed.tick();
expect(jest.getTimerCount()).toBe(0);
active.set(true);
TestBed.tick();
expect(jest.getTimerCount()).toBe(1);
});
it('reads the clock at once when the sources rail is re-enabled', () => {
active.set(false);
TestBed.tick();
// Past the expiry while the rail is off: no boundary is left to wait
// for, so only the re-enable can refresh the cached badge.
facts.set(new Map([['xtream', expiringIn(60_000)]]));
TestBed.tick();
jest.advanceTimersByTime(3 * 60_000);
const before = clock();
active.set(true);
TestBed.tick();
expect(clock()).toBe(Date.now());
expect(clock()).toBeGreaterThan(before);
});
it('keeps rechecking an expired timestamp hourly in case the clock is corrected', () => {
facts.set(new Map([['xtream', expiringIn(-60_000)]]));
TestBed.tick();
expect(jest.getTimerCount()).toBe(1);
const before = clock();
jest.advanceTimersByTime(SOURCE_EXPIRY_MAX_WAIT_MS);
TestBed.tick();
expect(clock()).toBe(before + SOURCE_EXPIRY_MAX_WAIT_MS);
});
});
@@ -0,0 +1,95 @@
import {
DestroyRef,
effect,
inject,
signal,
untracked,
type Signal,
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
import {
nextSourceExpiryChangeMs,
SOURCE_EXPIRY_MAX_WAIT_MS,
type SourceExpiryFacts,
} from '@iptvnator/workspace/dashboard/data-access';
/** A boundary timer can fire a hair early; land safely past it. */
const BOUNDARY_SLACK_MS = 1_000;
/**
* Wall-clock ms that moves only when a source-expiry badge can change.
*
* 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; an hourly recheck for a timestamp that has
* already passed, in case the system clock is corrected backward), capped at {@link SOURCE_EXPIRY_MAX_WAIT_MS}
* because timers do not follow system sleep or clock changes. No timer is
* armed while the document is hidden or `active` is false; becoming visible
* re-reads the clock at once. Must be created in
* an injection context.
*/
export function createSourceExpiryClock(
facts: Signal<ReadonlyMap<string, SourceExpiryFacts>>,
/** False while no badge can render (the sources rail is disabled). */
active: Signal<boolean> = signal(true)
): Signal<number> {
const document = inject(DOCUMENT);
const now = signal(Date.now());
const visible = signal(!document.hidden);
effect((onCleanup) => {
// Read for the dependency; schedule from the real time, because the
// facts can change long after the clock last moved.
now();
// Hidden, or the sources rail is off: no badge is on screen. Becoming
// visible moves the clock and re-runs this effect; so does `active`.
if (!visible() || !active()) return;
const nowMs = Date.now();
let next: number | null = null;
// A badge derived from a timestamp can still change if the system
// clock is corrected backward, so it keeps the hourly recheck even
// with no boundary ahead. A portal-reported expiry is terminal.
let clockDependent = false;
for (const entry of facts().values()) {
if (!entry.reportedExpired && (entry.expiresAtSeconds ?? 0) > 0) {
clockDependent = true;
}
const change = nextSourceExpiryChangeMs(entry, nowMs);
if (change !== null && (next === null || change < next)) {
next = change;
}
}
// No badge can change any more: new facts re-run this effect.
if (next === null && !clockDependent) return;
const delay =
next === null
? SOURCE_EXPIRY_MAX_WAIT_MS
: Math.min(
Math.max(next - nowMs, 0) + BOUNDARY_SLACK_MS,
SOURCE_EXPIRY_MAX_WAIT_MS
);
const timer = setTimeout(() => now.set(Date.now()), delay);
onCleanup(() => clearTimeout(timer));
});
// Re-enabling the rail must not show a badge cached while it was off: a
// boundary may have passed (an expired source has no next boundary to
// wait for), so read the clock at once, as becoming visible does.
let wasActive = untracked(active);
effect(() => {
const isActive = active();
if (isActive && !wasActive) untracked(() => now.set(Date.now()));
wasActive = isActive;
});
const onVisibilityChange = () => {
visible.set(!document.hidden);
if (!document.hidden) now.set(Date.now());
};
document.addEventListener('visibilitychange', onVisibilityChange);
inject(DestroyRef).onDestroy(() =>
document.removeEventListener('visibilitychange', onVisibilityChange)
);
return now.asReadonly();
}
@@ -7,7 +7,7 @@ import {
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval, map, startWith } from 'rxjs';
import { startWith } from 'rxjs';
import {
isStalkerAccountPlaylist,
isXtreamAccountPlaylist,
@@ -48,7 +48,6 @@ import {
DashboardTrendingService,
GlobalRecentItem,
resolveSourceExpiryBadge,
SOURCE_EXPIRY_TICK_MS,
} from '@iptvnator/workspace/dashboard/data-access';
import { createDashboardRailSkeletons } from './dashboard-rail-skeletons';
import { DashboardRailComponent } from './dashboard-rail.component';
@@ -61,6 +60,8 @@ import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.pre
import { DashboardHeroComponent } from './dashboard-hero.component';
import { buildLiveEpgCardsForEnabledRails } from './dashboard-live-epg.utils';
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
import { createSourceExpiryClock } from './dashboard-source-expiry-clock';
import {
buildDashboardEpisodeBadge,
buildPlaybackPositionReloadKey,
@@ -99,7 +100,11 @@ import type {
host: {
'[class.rails-page-host--empty]': 'ready() && !hasPlaylists()',
},
providers: [DashboardLiveEpgPresenter, DashboardPortalLiveEpgPresenter],
providers: [
DashboardLiveEpgClock,
DashboardLiveEpgPresenter,
DashboardPortalLiveEpgPresenter,
],
})
export class WorkspaceDashboardRailsComponent {
readonly data = inject(DashboardDataService);
@@ -260,14 +265,12 @@ export class WorkspaceDashboardRailsComponent {
: this.t('WORKSPACE.DASHBOARD.TMDB_RECOMMENDED');
});
// Minute heartbeat for the expiry badges: resolveSourceExpiryBadge reads
// the wall clock, so without a reactive tick a dashboard left open would
// never cross a day-countdown or expiration boundary. interval() emits
// 0 first — shifted by one so it differs from initialValue, otherwise
// the signal's equality check would swallow the first tick.
private readonly sourceExpiryTick = toSignal(
interval(SOURCE_EXPIRY_TICK_MS).pipe(map((tick) => tick + 1)),
{ initialValue: 0 }
// resolveSourceExpiryBadge reads the wall clock, so a dashboard left
// open needs a reactive clock to cross a day-countdown or expiration
// boundary. It moves only at those boundaries, not on a polling tick.
private readonly sourceExpiryNow = createSourceExpiryClock(
this.sourceExpiry.facts,
computed(() => this.dashboardRails().recentSources)
);
readonly sourceCards = computed<DashboardRailCard[]>(() => {
@@ -611,12 +614,12 @@ export class WorkspaceDashboardRailsComponent {
private buildSourceExpiryBadge(
playlistId: string
): DashboardRailCard['expiryBadge'] {
// Reactive read: ties the wall-clock evaluation below to the minute
// tick (this method only runs inside the sourceCards computed).
this.sourceExpiryTick();
// Reactive read: ties the evaluation below to the expiry clock (this
// method only runs inside the sourceCards computed). Date.now() keeps
// a recompute for any other reason on the real time.
const badge = resolveSourceExpiryBadge(
this.sourceExpiry.facts().get(playlistId),
Date.now()
Math.max(this.sourceExpiryNow(), Date.now())
);
if (!badge) {
return null;