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

Three timers kept an idle dashboard busy (plan D3 audit):

- The portal live-EPG presenter ran a 30 s interval even with no portal
  card wanted, so every dashboard paid a full change-detection pass per
  tick. A component-provided DashboardLiveEpgClock now drives both live-EPG
  presenters and every progress bar. It runs only while one of them has a
  live card and only while the document is visible, and reads the clock at
  once when it starts again.
- The XMLTV presenter re-asked every scope and emitted a new Map on each
  tick. It now re-asks only after a programme on air has ended or while a
  key has no programme, and drops unchanged answers. Progress still moves
  on each tick because details read the clock signal.
- The source-expiry badges polled the clock every minute and rebuilt the
  sources rail. createSourceExpiryClock arms one timer for the next day
  boundary (nextSourceExpiryChangeMs), capped at an hour, and none when no
  badge can change.

The rail rebuilt its scroll position and IntersectionObserver targets on
every new items array; it now does so only when card ids or order change,
which also stops a user-scrolled rail from snapping back each tick.

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:55 +02:00
1 parent 963a431bb7
commit 4e512c1fd9
16 files changed
+647 -61

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.
+19 -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,14 @@ 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 or
while a key has no programme, and 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 +226,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 +401,8 @@ 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, and re-reads the clock when the page becomes visible.
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,33 @@ 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 it never will again. 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,4 +1,4 @@
import { signal } from '@angular/core';
import { computed, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { EMPTY, of, throwError } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
@@ -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';
@@ -89,6 +90,7 @@ describe('DashboardLiveEpgPresenter', () => {
TestBed.configureTestingModule({
providers: [
DashboardLiveEpgClock,
DashboardLiveEpgPresenter,
{
provide: DashboardPortalLiveEpgPresenter,
@@ -345,6 +347,45 @@ describe('DashboardLiveEpgPresenter', () => {
);
});
it('asks a guide again only 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('Tagesschau')]]))
);
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);
TestBed.tick();
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2);
});
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 +425,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,19 @@ import {
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import {
catchError,
combineLatest,
defaultIfEmpty,
distinctUntilChanged,
filter,
forkJoin,
interval,
map,
of,
startWith,
switchMap,
tap,
} from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import {
epgProviderClockMs,
normalizeDashboardRailsSettings,
type EpgProgram,
type PortalActivityItem,
@@ -31,15 +34,17 @@ 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,
sameLiveEpgAnswers,
type DashboardLiveEpgDetails,
type DashboardLiveEpgLookupGroup,
} from './dashboard-live-epg.utils';
@@ -88,6 +93,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 +146,45 @@ 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);
// 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.
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;
return this.now$.pipe(
filter((nowMs) =>
liveEpgAnswersNeedRefresh(
answers,
groups,
epgProviderClockMs(nowMs, offsetMinutes)
)
),
switchMap(() =>
forkJoin(
groups.map((group) => this.askScope(group))
).pipe(map((scopes) => mergeAnswers(scopes)))
),
tap((merged) => (answers = merged)),
distinctUntilChanged(sameLiveEpgAnswers)
);
})
),
{ initialValue: new Map<string, EpgProgram | null>() }
);
@@ -183,6 +212,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 +276,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(),
@@ -250,3 +250,53 @@ 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 (by time and title). */
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 ||
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,44 @@ 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('shows the placeholder only while a live card is pending its first answer', async () => {
installObservers(false);
const { fixture } = await render([
@@ -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,76 @@
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>;
const expiringIn = (ms: number): SourceExpiryFacts => ({
expiresAtSeconds: (nowMs + ms) / 1000,
reportedExpired: false,
});
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(nowMs);
facts = signal<ReadonlyMap<string, SourceExpiryFacts>>(new Map());
clock = TestBed.runInInjectionContext(() =>
createSourceExpiryClock(facts)
);
TestBed.tick();
});
afterEach(() => {
TestBed.resetTestingModule();
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);
});
});
@@ -0,0 +1,62 @@
import {
DestroyRef,
effect,
inject,
signal,
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), 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
* an injection context.
*/
export function createSourceExpiryClock(
facts: Signal<ReadonlyMap<string, SourceExpiryFacts>>
): Signal<number> {
const document = inject(DOCUMENT);
const now = signal(Date.now());
effect((onCleanup) => {
const nowMs = now();
let next: number | null = null;
for (const entry of facts().values()) {
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) return;
const delay = 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));
});
const onVisibilityChange = () => {
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,11 @@ 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
);
readonly sourceCards = computed<DashboardRailCard[]>(() => {
@@ -611,12 +613,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;