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,6 +1,7 @@
import {
extractXtreamCredentialsFromUrl,
normalizeXtreamServerUrl,
resolveXtreamPortalExpiration,
resolveXtreamPortalStatus,
} from './xtream-portal.utils';
@@ -104,4 +105,39 @@ describe('xtream portal utilities', () => {
).toBe('expired');
});
});
describe('resolveXtreamPortalExpiration', () => {
it('parses numeric-string exp_date into unix seconds', () => {
expect(
resolveXtreamPortalExpiration({
user_info: { exp_date: '1790000000' },
})
).toBe(1_790_000_000);
});
it('treats unlimited and absent expirations as null', () => {
expect(
resolveXtreamPortalExpiration({
user_info: { exp_date: '0' },
})
).toBeNull();
expect(
resolveXtreamPortalExpiration({
user_info: { exp_date: '' },
})
).toBeNull();
expect(
resolveXtreamPortalExpiration({ user_info: {} })
).toBeNull();
expect(resolveXtreamPortalExpiration(null)).toBeNull();
});
it('rejects non-numeric exp_date values', () => {
expect(
resolveXtreamPortalExpiration({
user_info: { exp_date: 'never' },
})
).toBeNull();
});
});
});
@@ -102,6 +102,17 @@ export function resolveXtreamPortalStatus(
return 'active';
}
/**
* Reads the account expiration from an Xtream `get_account_info`-style
* response as unix seconds. Returns null when the portal doesn't report one
* (unlimited accounts answer with empty/zero/absent `exp_date`).
*/
export function resolveXtreamPortalExpiration(
response: XtreamPortalStatusResponseLike | null | undefined
): number | null {
return parseXtreamExpiration(response?.user_info?.exp_date);
}
function normalizeAuthValue(
value: boolean | number | string | null | undefined
): boolean | null {