From 13de38ebe560269fd04360b9f99c33a7599cd517 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 21:14:49 +0200 Subject: [PATCH] fix(sources): keep counting a failure's duration while the list stays open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/src/lib/source-row/source-clock.ts | 22 +++++++++++++++++++ .../source-row/source-row.component.spec.ts | 20 +++++++++++++++++ .../lib/source-row/source-row.component.ts | 9 +++++++- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 libs/playlist/shared/ui/src/lib/source-row/source-clock.ts diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-clock.ts b/libs/playlist/shared/ui/src/lib/source-row/source-clock.ts new file mode 100644 index 000000000..8838d7c3d --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-row/source-clock.ts @@ -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)); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts index b784566db..2073b9fac 100644 --- a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts @@ -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(); diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts index 1febf5dbd..a6b11f453 100644 --- a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts +++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts @@ -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() {