From 0ba3386942fd8e0189d6bce90a84d0e435ae51c4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 12:15:12 +0200 Subject: [PATCH] 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 --- apps/web/src/m3-theme.scss | 19 +++++++++++++++++++ .../src/lib/dashboard-source-expiry.util.ts | 8 ++++++++ .../lib/rails/dashboard-rail.component.scss | 19 +++++++++++++------ .../workspace-dashboard-rails.component.ts | 17 +++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index 4f8ac3325..235010a23 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -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, diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts index 64a761177..0e1627099 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts @@ -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; /** diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index 45d4f7136..b7fb55c6b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -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 { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 790a70d84..074d9dcf8 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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(() => 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()