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

@@ -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;
}