diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 4c46108b0..a749d9c77 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts index f4c51ce6c..1fef554da 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts @@ -14,6 +14,7 @@ describe('createSourceExpiryClock', () => { typeof signal> >; let clock: Signal; + let active: ReturnType>; const expiringIn = (ms: number): SourceExpiryFacts => ({ expiresAtSeconds: (nowMs + ms) / 1000, @@ -31,8 +32,9 @@ describe('createSourceExpiryClock', () => { get: () => hidden, }); facts = signal>(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); + }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts index 847af6f99..5a0c3a19d 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts @@ -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> + facts: Signal>, + /** False while no badge can render (the sources rail is disabled). */ + active: Signal = signal(true) ): Signal { 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()) { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 56bda9751..d9c15f75b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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(() => {