feat(dashboard): subscription-expiry warning badge on source cards (#1342)

* feat(dashboard): warn on source cards when a portal subscription expires soon

Dashboard source cards now carry a passive expiry chip: amber "Expires in
N d" within 7 days of the subscription lapsing, error-toned "Expired" once
it has. Account details stay behind the card's ⋮ → Account info.

Xtream expirations ride on the playlist switcher's cached PortalStatusService
check — checkPortalStatusDetails() now surfaces the parsed exp_date from the
same round-trip, so the dashboard adds no extra portal calls. Stalker
expirations come from the stalkerAccountInfo snapshot persisted at import;
it lives in the playlist payload (meta rows carry payload: null), so each
Stalker source costs one full-playlist read memoized on the playlist's
update timestamp.

New i18n keys added to all 19 locales via the i18n-fill merger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): address review feedback on expiry badges

- Recompute expiry badges on a minute tick so a dashboard left open
  crosses day-countdown and expiration boundaries (Greptile P1 / Codex P2)
- Gate the expiry refresh on the recent-sources rail setting so hidden
  rails cost no portal checks or playlist reads (Codex P2)
- Move chip colors to theme-aware tokens in m3-theme.scss; both themes
  now hold >= 4.5:1 small-text contrast (light warn 5.3:1, light expired
  5.4:1, dark warn 7.4:1, dark expired 6.0:1) (Codex P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): make expiry-badge labels depend on the language signal

sourceCards previously relied on getPlaylistProvider's indirect language
read; the translate.instant() labels now read languageTick explicitly
(mirroring trendingCards). Also shift the minute tick by one so the
interval's first 0 differs from initialValue — the signal equality check
was swallowing the first heartbeat, delaying it to two minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-02 13:24:16 +02:00
1 parent 8f9e78ff90
commit a6186a46c8
35 files changed
+912 -35

No files matched your search

@@ -1,2 +1,4 @@
export * from './lib/dashboard-data.service';
export * from './lib/dashboard-source-expiry.service';
export * from './lib/dashboard-source-expiry.util';
export * from './lib/dashboard-trending.service';
@@ -0,0 +1,167 @@
import { Injectable, NgZone, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { normalizeStoredStalkerAccountInfo } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService, PortalStatusService } from '@iptvnator/services';
import {
PlaylistMeta,
isStalkerAccountPlaylist,
isXtreamAccountPlaylist,
} from '@iptvnator/shared/interfaces';
import type { SourceExpiryFacts } from './dashboard-source-expiry.util';
/**
* Collects subscription-expiry facts for the dashboard's source cards.
*
* Xtream expirations ride on the same cached `PortalStatusService` check the
* playlist switcher makes (30s TTL + in-flight dedup), so the dashboard adds
* no portal round-trips beyond that shared check. Stalker expirations come
* from the `stalkerAccountInfo` snapshot persisted at import — it lives in
* the playlist payload (not on the meta rows), so each Stalker source costs
* one full-playlist read, memoized until the playlist is refreshed.
*/
@Injectable({ providedIn: 'root' })
export class DashboardSourceExpiryService {
private readonly ngZone = inject(NgZone);
private readonly playlistsService = inject(PlaylistsService);
private readonly portalStatusService = inject(PortalStatusService);
private readonly factsState = signal<ReadonlyMap<string, SourceExpiryFacts>>(
new Map()
);
/** Facts per playlist id; entries land as each source's lookup resolves. */
readonly facts = this.factsState.asReadonly();
/**
* Stalker snapshots only change on re-import/refresh; fingerprinting on
* the playlist's update timestamp keeps dashboard revisits IPC-free.
*/
private readonly stalkerFactsCache = new Map<
string,
{ fingerprint: string; facts: SourceExpiryFacts | null }
>();
private refreshRound = 0;
/** Refreshes facts for the given sources; prunes ids no longer present. */
async refresh(playlists: readonly PlaylistMeta[]): Promise<void> {
const round = ++this.refreshRound;
const validIds = new Set(playlists.map((playlist) => playlist._id));
this.applyIfCurrent(round, (map) => {
let next: Map<string, SourceExpiryFacts> | null = null;
for (const id of map.keys()) {
if (!validIds.has(id)) {
next ??= new Map(map);
next.delete(id);
}
}
return next ?? map;
});
await Promise.all(
playlists.map(async (playlist) => {
const facts = await this.loadFacts(playlist);
this.applyIfCurrent(round, (map) => {
if ((map.get(playlist._id) ?? null) === facts) {
return map;
}
const next = new Map(map);
if (facts === null) {
next.delete(playlist._id);
} else {
next.set(playlist._id, facts);
}
return next;
});
})
);
}
private async loadFacts(
playlist: PlaylistMeta
): Promise<SourceExpiryFacts | null> {
if (isXtreamAccountPlaylist(playlist)) {
return this.loadXtreamFacts(playlist);
}
if (isStalkerAccountPlaylist(playlist)) {
return this.loadStalkerFacts(playlist);
}
return null;
}
private async loadXtreamFacts(
playlist: PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
}
): Promise<SourceExpiryFacts | null> {
try {
const details =
await this.portalStatusService.checkPortalStatusDetails(
playlist.serverUrl,
playlist.username,
playlist.password
);
// An unreachable portal is unknown, not expired — no badge.
if (details.status === 'unavailable') {
return null;
}
return {
expiresAtSeconds: details.expiresAtSeconds,
reportedExpired: details.status === 'expired',
};
} catch {
return null;
}
}
private async loadStalkerFacts(
playlist: PlaylistMeta
): Promise<SourceExpiryFacts | null> {
const fingerprint = String(
playlist.updateDate ?? playlist.importDate ?? ''
);
const cached = this.stalkerFactsCache.get(playlist._id);
if (cached && cached.fingerprint === fingerprint) {
return cached.facts;
}
let facts: SourceExpiryFacts | null;
try {
const fullPlaylist = await firstValueFrom(
this.playlistsService.getPlaylistById(playlist._id)
);
const snapshot = normalizeStoredStalkerAccountInfo(
fullPlaylist?.stalkerAccountInfo
);
facts =
snapshot?.expireDate !== undefined
? {
expiresAtSeconds: snapshot.expireDate,
reportedExpired: false,
}
: null;
} catch {
// Don't cache failures — the next refresh should retry the read.
return null;
}
this.stalkerFactsCache.set(playlist._id, { fingerprint, facts });
return facts;
}
private applyIfCurrent(
round: number,
update: (
map: ReadonlyMap<string, SourceExpiryFacts>
) => ReadonlyMap<string, SourceExpiryFacts>
): void {
if (round !== this.refreshRound) {
return;
}
this.ngZone.run(() => {
this.factsState.update(update);
});
}
}
@@ -0,0 +1,274 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { PlaylistsService, PortalStatusService } from '@iptvnator/services';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DashboardSourceExpiryService } from './dashboard-source-expiry.service';
import {
SOURCE_EXPIRY_WARNING_DAYS,
resolveSourceExpiryBadge,
} from './dashboard-source-expiry.util';
const DAY_SECONDS = 86_400;
describe('resolveSourceExpiryBadge', () => {
const nowMs = Date.UTC(2026, 7, 1, 12, 0, 0);
const nowSeconds = nowMs / 1000;
it('returns null without facts', () => {
expect(resolveSourceExpiryBadge(null, nowMs)).toBeNull();
expect(resolveSourceExpiryBadge(undefined, nowMs)).toBeNull();
});
it('returns expired when the portal reports an expired account without a timestamp', () => {
expect(
resolveSourceExpiryBadge(
{ expiresAtSeconds: null, reportedExpired: true },
nowMs
)
).toEqual({ kind: 'expired' });
});
it('returns expired for timestamps in the past', () => {
expect(
resolveSourceExpiryBadge(
{ expiresAtSeconds: nowSeconds - 60, reportedExpired: false },
nowMs
)
).toEqual({ kind: 'expired' });
});
it('warns with ceil-ed days inside the warning window', () => {
expect(
resolveSourceExpiryBadge(
{
expiresAtSeconds: nowSeconds + 2.5 * DAY_SECONDS,
reportedExpired: false,
},
nowMs
)
).toEqual({ kind: 'expiring', daysLeft: 3 });
});
it('includes the boundary day but not one hour past it', () => {
expect(
resolveSourceExpiryBadge(
{
expiresAtSeconds:
nowSeconds + SOURCE_EXPIRY_WARNING_DAYS * DAY_SECONDS,
reportedExpired: false,
},
nowMs
)
).toEqual({ kind: 'expiring', daysLeft: SOURCE_EXPIRY_WARNING_DAYS });
expect(
resolveSourceExpiryBadge(
{
expiresAtSeconds:
nowSeconds +
SOURCE_EXPIRY_WARNING_DAYS * DAY_SECONDS +
3600,
reportedExpired: false,
},
nowMs
)
).toBeNull();
});
it('treats unknown and sentinel expirations as no badge', () => {
expect(
resolveSourceExpiryBadge(
{ expiresAtSeconds: null, reportedExpired: false },
nowMs
)
).toBeNull();
expect(
resolveSourceExpiryBadge(
{ expiresAtSeconds: 0, reportedExpired: false },
nowMs
)
).toBeNull();
});
});
describe('DashboardSourceExpiryService', () => {
let service: DashboardSourceExpiryService;
let portalStatusService: { checkPortalStatusDetails: jest.Mock };
let playlistsService: { getPlaylistById: jest.Mock };
const xtreamPlaylist = {
_id: 'xtream-1',
title: 'Xtream',
serverUrl: 'https://provider.example.test',
username: 'demo',
password: 'secret',
} as PlaylistMeta;
const stalkerPlaylist = {
_id: 'stalker-1',
title: 'Stalker',
portalUrl: 'https://stalker.example.test/portal.php',
macAddress: '00:1A:79:00:00:01',
updateDate: 1_750_000_000_000,
} as PlaylistMeta;
const m3uPlaylist = {
_id: 'm3u-1',
title: 'M3U',
url: 'https://example.com/playlist.m3u',
} as PlaylistMeta;
beforeEach(() => {
portalStatusService = { checkPortalStatusDetails: jest.fn() };
playlistsService = { getPlaylistById: jest.fn() };
TestBed.configureTestingModule({
providers: [
DashboardSourceExpiryService,
{
provide: PortalStatusService,
useValue: portalStatusService,
},
{ provide: PlaylistsService, useValue: playlistsService },
],
});
service = TestBed.inject(DashboardSourceExpiryService);
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('collects Xtream facts from the shared status details', async () => {
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3 * DAY_SECONDS;
portalStatusService.checkPortalStatusDetails.mockResolvedValue({
status: 'active',
expiresAtSeconds,
});
await service.refresh([xtreamPlaylist]);
expect(service.facts().get('xtream-1')).toEqual({
expiresAtSeconds,
reportedExpired: false,
});
expect(
portalStatusService.checkPortalStatusDetails
).toHaveBeenCalledWith(
'https://provider.example.test',
'demo',
'secret'
);
});
it('marks Xtream sources the portal reports as expired', async () => {
portalStatusService.checkPortalStatusDetails.mockResolvedValue({
status: 'expired',
expiresAtSeconds: null,
});
await service.refresh([xtreamPlaylist]);
expect(service.facts().get('xtream-1')).toEqual({
expiresAtSeconds: null,
reportedExpired: true,
});
});
it('records no facts for unreachable portals or failed checks', async () => {
portalStatusService.checkPortalStatusDetails.mockResolvedValueOnce({
status: 'unavailable',
expiresAtSeconds: null,
});
await service.refresh([xtreamPlaylist]);
expect(service.facts().has('xtream-1')).toBe(false);
portalStatusService.checkPortalStatusDetails.mockRejectedValueOnce(
new Error('offline')
);
await service.refresh([xtreamPlaylist]);
expect(service.facts().has('xtream-1')).toBe(false);
});
it('reads Stalker expirations from the stored snapshot and memoizes the read', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
// Stored verbatim at import — a date string despite the
// declared number type; the normalizer must parse it.
stalkerAccountInfo: { expireDate: '2027-10-01' },
})
);
await service.refresh([stalkerPlaylist]);
const facts = service.facts().get('stalker-1');
expect(facts?.reportedExpired).toBe(false);
expect(facts?.expiresAtSeconds).toBe(
Math.round(new Date(2027, 9, 1).getTime() / 1000)
);
await service.refresh([stalkerPlaylist]);
expect(playlistsService.getPlaylistById).toHaveBeenCalledTimes(1);
});
it('re-reads the Stalker snapshot after a playlist refresh changes the fingerprint', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
stalkerAccountInfo: { expireDate: '2027-10-01' },
})
);
await service.refresh([stalkerPlaylist]);
await service.refresh([
{ ...stalkerPlaylist, updateDate: 1_760_000_000_000 },
]);
expect(playlistsService.getPlaylistById).toHaveBeenCalledTimes(2);
});
it('records no facts when the snapshot is absent or unlimited', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
stalkerAccountInfo: { expireDate: '-1' },
})
);
await service.refresh([stalkerPlaylist]);
expect(service.facts().has('stalker-1')).toBe(false);
});
it('does not cache failed Stalker reads', async () => {
playlistsService.getPlaylistById.mockReturnValueOnce(
throwError(() => new Error('db down'))
);
await service.refresh([stalkerPlaylist]);
expect(service.facts().has('stalker-1')).toBe(false);
playlistsService.getPlaylistById.mockReturnValueOnce(
of({
_id: 'stalker-1',
stalkerAccountInfo: { expireDate: '2027-10-01' },
})
);
await service.refresh([stalkerPlaylist]);
expect(service.facts().has('stalker-1')).toBe(true);
});
it('ignores M3U playlists and prunes removed sources', async () => {
portalStatusService.checkPortalStatusDetails.mockResolvedValue({
status: 'active',
expiresAtSeconds: Math.floor(Date.now() / 1000) + DAY_SECONDS,
});
await service.refresh([xtreamPlaylist, m3uPlaylist]);
expect(service.facts().has('xtream-1')).toBe(true);
expect(service.facts().has('m3u-1')).toBe(false);
await service.refresh([m3uPlaylist]);
expect(service.facts().has('xtream-1')).toBe(false);
});
});
@@ -0,0 +1,58 @@
/** Warn this many days before a portal subscription lapses. */
export const SOURCE_EXPIRY_WARNING_DAYS = 7;
/**
* How often badge consumers should re-evaluate {@link resolveSourceExpiryBadge}
* against the wall clock. The badge state only moves at day granularity, but a
* dashboard left open must still cross day-countdown and expiration
* boundaries without a remount; a minute tick is imperceptibly cheap.
*/
export const SOURCE_EXPIRY_TICK_MS = 60_000;
const SECONDS_PER_DAY = 86_400;
/**
* What a source's account facts say about its subscription lifetime.
* `reportedExpired` covers portals that answer with an expired account
* status without a usable timestamp; `expiresAtSeconds` is unix seconds
* when the portal reports one.
*/
export interface SourceExpiryFacts {
expiresAtSeconds: number | null;
reportedExpired: boolean;
}
export type SourceExpiryBadge =
| { kind: 'expired' }
| { kind: 'expiring'; daysLeft: number };
/**
* Decides whether a source card should carry an expiry badge. Returns null
* for unknown expirations and for subscriptions further out than
* `warningDays` — the badge is a warning, not a countdown.
*/
export function resolveSourceExpiryBadge(
facts: SourceExpiryFacts | null | undefined,
nowMs: number,
warningDays: number = SOURCE_EXPIRY_WARNING_DAYS
): SourceExpiryBadge | null {
if (!facts) {
return null;
}
if (facts.reportedExpired) {
return { kind: 'expired' };
}
const expiresAt = facts.expiresAtSeconds;
if (expiresAt === null || expiresAt <= 0) {
return null;
}
const secondsLeft = expiresAt - nowMs / 1000;
if (secondsLeft <= 0) {
return { kind: 'expired' };
}
const daysLeft = Math.ceil(secondsLeft / SECONDS_PER_DAY);
return daysLeft <= warningDays ? { kind: 'expiring', daysLeft } : null;
}