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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 20:48:56 +02:00
1 parent a989eca4bf
commit e474555726
3 files changed
+34 -10

No files matched your search

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