fix(dashboard): pin only the hero row for portal EPG

The consolidation derived the pinned key from the first entry of the portal source list, which is a favourite when the hero rail is hidden. Pinning that keeps asking for a card nobody can see. The hero row is now its own computed, gated on the rail like the source list is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-09-20 22:01:35 +02:00
1 parent 9c5c256b0d
commit 1a2e25645d
2 files changed
+62 -17

No files matched your search

@@ -2,7 +2,12 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { EMPTY, of, throwError } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
DEFAULT_DASHBOARD_RAILS_SETTINGS,
type EpgProgram,
type PlaylistMeta,
type PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
@@ -37,6 +42,10 @@ describe('DashboardLiveEpgPresenter', () => {
let presenter: DashboardLiveEpgPresenter;
let getCurrentProgramsForChannels: jest.Mock;
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
let recentItems: ReturnType<typeof signal<PortalActivityItem[]>>;
let dashboardRails: ReturnType<
typeof signal<typeof DEFAULT_DASHBOARD_RAILS_SETTINGS>
>;
/** Portal answers are the sibling presenter's job; stub it out here. */
let portal: {
connect: jest.Mock;
@@ -64,6 +73,8 @@ describe('DashboardLiveEpgPresenter', () => {
{ _id: 'portal', serverUrl: 'http://portal' } as PlaylistMeta,
]);
recentItems = signal<PortalActivityItem[]>([]);
dashboardRails = signal({ ...DEFAULT_DASHBOARD_RAILS_SETTINGS });
portal = {
connect: jest.fn(),
setPinnedKeys: jest.fn(),
@@ -84,7 +95,7 @@ describe('DashboardLiveEpgPresenter', () => {
useValue: {
playlists,
// The presenter also derives the portal source list.
globalRecentItems: signal([]),
globalRecentItems: recentItems,
globalFavoriteLiveItems: signal([]),
globalRecentLiveItems: signal([]),
},
@@ -97,7 +108,7 @@ describe('DashboardLiveEpgPresenter', () => {
provide: SettingsStore,
useValue: {
resolvedEpgOffsetMinutes: () => 0,
dashboardRails: signal(undefined),
dashboardRails,
},
},
],
@@ -166,6 +177,32 @@ describe('DashboardLiveEpgPresenter', () => {
);
});
it('pins the hero row only while the hero rail shows one', () => {
const heroLive = {
id: 'x-7',
title: 'Hero channel',
type: 'live',
source: 'xtream',
playlist_id: 'p',
category_id: '1',
xtream_id: 7,
} as PortalActivityItem;
recentItems.set([heroLive]);
TestBed.tick();
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith(['xtream::p::7']);
// With the rail hidden nothing is pinned: the first portal row is
// then a favourite, and pinning it would keep asking for a card
// nobody can see.
dashboardRails.set({
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
hero: false,
});
TestBed.tick();
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([null]);
});
it('prefers the portal answer and forwards what the portal presenter owns', () => {
const xmltvCard = card({
id: 'x',
@@ -19,12 +19,12 @@ import {
switchMap,
} from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import type {
EpgProgram,
PortalActivityItem,
import {
normalizeDashboardRailsSettings,
type EpgProgram,
type PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { normalizeDashboardRailsSettings } from '@iptvnator/shared/interfaces';
import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
import {
buildDashboardPortalLiveEpgKey,
@@ -131,17 +131,25 @@ export class DashboardLiveEpgPresenter {
{ initialValue: new Map<string, EpgProgram | null>() }
);
private readonly rails = computed(() =>
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
);
/** The live row behind the hero panel, when that rail shows one. */
private readonly heroLiveItem = computed<PortalActivityItem | null>(() => {
const hero = this.data.globalRecentItems()[0] ?? null;
return this.rails().hero && hero?.type === 'live' ? hero : null;
});
// The Xtream/Stalker live rows behind the hero and the two live rails.
// Their programmes come from the portal, asked for lazily per visible
// card; M3U rows stay on the XMLTV batch above.
private readonly portalItems = computed<readonly PortalActivityItem[]>(
() => {
const rails = normalizeDashboardRailsSettings(
this.settingsStore.dashboardRails?.()
);
const hero = this.data.globalRecentItems()[0] ?? null;
const rails = this.rails();
const hero = this.heroLiveItem();
return [
...(rails.hero && hero?.type === 'live' ? [hero] : []),
...(hero ? [hero] : []),
...(rails.liveFavorites
? this.data
.globalFavoriteLiveItems()
@@ -160,12 +168,12 @@ export class DashboardLiveEpgPresenter {
this.portal.connect(this.portalItems);
// The hero sits at the top of the page and is never scrolled into
// view, so its key is wanted regardless of what the rails report.
// Only the hero: the first entry of `portalItems` is a favourite
// when that rail is hidden, and pinning it would keep asking for a
// card nobody can see.
effect(() => {
const [first] = this.portalItems();
const heroKey =
first?.type === 'live'
? buildDashboardPortalLiveEpgKey(first)
: null;
const hero = this.heroLiveItem();
const heroKey = hero ? buildDashboardPortalLiveEpgKey(hero) : null;
untracked(() => this.portal.setPinnedKeys([heroKey]));
});
}