fix(dashboard): stop the expiry clock while the sources rail is disabled

The expiry facts live in a root service and outlive the rail, so the clock
kept arming its boundary/hourly timeout with no badge on screen. Gate it on
dashboardRails().recentSources.

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 b15c758788
commit 97ad694549
4 files changed
+26 -10

No files matched your search

+2 -2
View File
@@ -407,7 +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. It arms no timer while the page is hidden and
re-reads the clock when the page becomes visible. It schedules from the real
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.
@@ -14,6 +14,7 @@ describe('createSourceExpiryClock', () => {
typeof signal<ReadonlyMap<string, SourceExpiryFacts>>
>;
let clock: Signal<number>;
let active: ReturnType<typeof signal<boolean>>;
const expiringIn = (ms: number): SourceExpiryFacts => ({
expiresAtSeconds: (nowMs + ms) / 1000,
@@ -31,8 +32,9 @@ describe('createSourceExpiryClock', () => {
get: () => hidden,
});
facts = signal<ReadonlyMap<string, SourceExpiryFacts>>(new Map());
active = signal(true);
clock = TestBed.runInInjectionContext(() =>
createSourceExpiryClock(facts)
createSourceExpiryClock(facts, active)
);
TestBed.tick();
});
@@ -113,4 +115,15 @@ describe('createSourceExpiryClock', () => {
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);
});
});
@@ -16,12 +16,14 @@ const BOUNDARY_SLACK_MS = 1_000;
* 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. No timer is
* armed while the document is hidden; becoming visible re-reads the clock
* at once. Must be created in
* 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>>
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());
@@ -31,9 +33,9 @@ export function createSourceExpiryClock(
// 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;
// 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;
for (const entry of facts().values()) {
@@ -269,7 +269,8 @@ export class WorkspaceDashboardRailsComponent {
// 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
this.sourceExpiry.facts,
computed(() => this.dashboardRails().recentSources)
);
readonly sourceCards = computed<DashboardRailCard[]>(() => {