From e474555726b2526b8a337d5877308f5c7989756e Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 28 Sep 2026 22:31:13 +0200 Subject: [PATCH] fix(dashboard): keep rechecking timestamp-expired sources hourly A badge derived from a timestamp in the past can still change if the system clock is corrected backward; the minute heartbeat used to recover from that. Keep the hourly recheck for such facts. A portal-reported expiry stays terminal and arms no timer. Co-Authored-By: Claude Opus 5.5 --- .../src/lib/dashboard-source-expiry.util.ts | 10 ++++---- .../dashboard-source-expiry-clock.spec.ts | 11 +++++++++ .../rails/dashboard-source-expiry-clock.ts | 23 ++++++++++++++----- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts index bffe34d19..fc9507174 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts @@ -57,10 +57,12 @@ export function resolveSourceExpiryBadge( /** * 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. + * 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, 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 8b8c72843..813804927 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 @@ -143,4 +143,15 @@ describe('createSourceExpiryClock', () => { 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); + }); }); 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 b552e2393..3a22d481e 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 @@ -21,7 +21,8 @@ const BOUNDARY_SLACK_MS = 1_000; * * 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} + * 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 @@ -45,18 +46,28 @@ export function createSourceExpiryClock( 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) return; - const delay = Math.min( - Math.max(next - nowMs, 0) + BOUNDARY_SLACK_MS, - SOURCE_EXPIRY_MAX_WAIT_MS - ); + 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)); });