fix(sources): keep counting a failure's duration while the list stays open

Local Greptile (5/5, one P2) on the PR round: the row's status tag
formatted "Not responding · 3 days" against the time the row mounted (or
the language last changed), so a new failure never gained its duration
and an existing one froze while Sources stayed open. A shared root
SourceClock now ticks once a minute and only the status tag reads it, so
dates and the meta line do not recompute every minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 21:14:49 +02:00
1 parent dac79af54f
commit 13de38ebe5
3 files changed
+50 -1

No files matched your search

@@ -0,0 +1,22 @@
import { DestroyRef, Injectable, inject, signal } from '@angular/core';
/** Often enough for durations shown in whole hours and days. */
export const SOURCE_CLOCK_TICK_MS = 60_000;
/**
* One shared minute clock for source rows, so "Not responding · 3 days"
* moves on while a list stays open, without a timer per row.
*/
@Injectable({ providedIn: 'root' })
export class SourceClock {
private readonly state = signal(Date.now());
readonly now = this.state.asReadonly();
constructor() {
const timer = setInterval(
() => this.state.set(Date.now()),
SOURCE_CLOCK_TICK_MS
);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
}
}
@@ -1,8 +1,10 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import { PlaylistMeta, SourceStatus } from '@iptvnator/shared/interfaces';
import { SourceClock } from './source-clock';
import { SourceRowComponent } from './source-row.component';
const M3U: PlaylistMeta = {
@@ -132,6 +134,24 @@ describe('SourceRowComponent', () => {
).toBe('SOURCE_HEALTH.REASON.network');
});
it("keeps counting a failure's duration while the list stays open", () => {
const start = Date.UTC(2026, 9, 13, 12);
const now = signal(start);
TestBed.overrideProvider(SourceClock, { useValue: { now } });
statuses.set('m3u', {
reachability: 'bad',
failingSince: start - 10 * 60_000,
});
const element = render(M3U);
const tag = () =>
element.querySelector('.source-row__tag')?.textContent?.trim();
expect(tag()).toBe('WORKSPACE.SOURCES.ROW.UNREACHABLE');
now.set(start + 2 * 60 * 60_000);
fixture.detectChanges();
expect(tag()).toBe('WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR');
});
it('opens on click and on Enter, but not while busy', () => {
const element = render(M3U);
const opened = jest.fn();
@@ -35,6 +35,7 @@ import {
sourceMetaParts,
sourceStatusTag,
} from '@iptvnator/playlist/shared/util';
import { SourceClock } from './source-clock';
import { SourceTileComponent } from './source-tile.component';
/**
@@ -68,6 +69,7 @@ import { SourceTileComponent } from './source-tile.component';
})
export class SourceRowComponent {
private readonly statusService = inject(SourceStatusService);
private readonly clock = inject(SourceClock);
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
private readonly clipboard = inject(Clipboard);
@@ -137,8 +139,13 @@ export class SourceRowComponent {
readonly meta = computed(() =>
sourceMetaParts(this.playlist(), this.formatContext(), this.compact())
);
/** Reads the shared clock, so a failure's duration keeps counting. */
readonly tag = computed(() =>
sourceStatusTag(this.status(), this.formatContext(), this.compact())
sourceStatusTag(
this.status(),
{ ...this.formatContext(), now: this.clock.now() },
this.compact()
)
);
constructor() {