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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-02 12:15:12 +02:00
1 parent e7d32d7c61
commit 0ba3386942
4 files changed
+57 -6

No files matched your search

+19
View File
@@ -112,6 +112,14 @@ html {
--app-provider-stalker: #10b981;
--app-provider-m3u: #d97706;
// Subscription-expiry chips (dashboard source cards). Text/surface pairs
// are picked per theme to keep >= 4.5:1 small-text contrast against the
// composited chip background.
--app-expiry-warn-text: #8a5300;
--app-expiry-warn-surface: color-mix(in srgb, #d97706 16%, transparent);
--app-expiry-expired-text: #b3261e;
--app-expiry-expired-surface: color-mix(in srgb, #f44336 14%, transparent);
// Hero
--app-accent-gradient: linear-gradient(
135deg,
@@ -188,6 +196,17 @@ html {
--app-provider-stalker: #34d399;
--app-provider-m3u: #fbbf24;
// Subscription-expiry chips — brighter text for the dark surfaces;
// expired matches the --app-live-color accent family.
--app-expiry-warn-text: #fbbf24;
--app-expiry-warn-surface: color-mix(in srgb, #fbbf24 16%, transparent);
--app-expiry-expired-text: #ff8f8f;
--app-expiry-expired-surface: color-mix(
in srgb,
#ff8f8f 16%,
transparent
);
// Hero section
--app-accent-gradient: linear-gradient(
135deg,
@@ -1,6 +1,14 @@
/** 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;
/**
@@ -376,8 +376,9 @@
}
// Subscription-expiry chip on portal source cards. Mirrors the episode
// chip's geometry; colors follow the switcher's status palette (amber =
// expiring soon, red = already expired).
// chip's geometry; the theme-aware token pairs (m3-theme.scss) keep the
// small bold text at >= 4.5:1 contrast in both themes (amber = expiring
// soon, error tone = already expired).
.rail__card-expiry {
flex: 0 0 auto;
font-size: 0.66rem;
@@ -385,16 +386,22 @@
line-height: 1;
padding: 2px 6px;
border-radius: 4px;
background: color-mix(in srgb, #ff9800 16%, transparent);
color: #e68a00;
background: var(
--app-expiry-warn-surface,
color-mix(in srgb, #d97706 16%, transparent)
);
color: var(--app-expiry-warn-text, #8a5300);
letter-spacing: 0.02em;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.rail__card-expiry--expired {
background: color-mix(in srgb, #f44336 14%, transparent);
color: #f44336;
background: var(
--app-expiry-expired-surface,
color-mix(in srgb, #f44336 14%, transparent)
);
color: var(--app-expiry-expired-text, #b3261e);
}
.rail__card-subtitle {
@@ -48,6 +48,7 @@ import {
DashboardTrendingService,
GlobalRecentItem,
resolveSourceExpiryBadge,
SOURCE_EXPIRY_TICK_MS,
} from '@iptvnator/workspace/dashboard/data-access';
import type { DashboardHeroTmdbExtras } from './dashboard-hero-tmdb.service';
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
@@ -342,6 +343,14 @@ export class WorkspaceDashboardRailsComponent {
.map((item) => this.toTrendingCard(item));
});
// Minute heartbeat for the expiry badges: resolveSourceExpiryBadge reads
// the wall clock, so without a reactive tick a dashboard left open would
// never cross a day-countdown or expiration boundary.
private readonly sourceExpiryTick = toSignal(
interval(SOURCE_EXPIRY_TICK_MS).pipe(startWith(0)),
{ initialValue: 0 }
);
readonly sourceCards = computed<DashboardRailCard[]>(() =>
this.data.recentPlaylists().map((playlist) => ({
id: playlist._id,
@@ -417,7 +426,12 @@ export class WorkspaceDashboardRailsComponent {
// Subscription-expiry badges for the source cards. Xtream rides the
// shared PortalStatusService cache; Stalker reads the import-time
// snapshot (memoized per playlist), so this stays cheap on re-entry.
// Gated on the sources rail being enabled — with the rail hidden no
// badge can render, so the status checks would be pure waste.
effect(() => {
if (!this.dashboardRails().recentSources) {
return;
}
const playlists = this.data.recentPlaylists();
untracked(() => void this.sourceExpiry.refresh(playlists));
});
@@ -612,6 +626,9 @@ export class WorkspaceDashboardRailsComponent {
private buildSourceExpiryBadge(
playlistId: string
): DashboardRailCard['expiryBadge'] {
// Reactive read: ties the wall-clock evaluation below to the minute
// tick (this method only runs inside the sourceCards computed).
this.sourceExpiryTick();
const badge = resolveSourceExpiryBadge(
this.sourceExpiry.facts().get(playlistId),
Date.now()