refactor(dashboard): live rail now favorites-first; drop mixed Favorites rail

Two related dashboard fixes driven by the v0.22 mockup intent and the
UX-audit feedback that the rail labels lied about their data:

1. Live rail is favorites-first
   - Source: globalFavoriteLiveItems() — the channels the user actually
     starred. Falls back to globalRecentLiveItems() when no favorites
     exist so fresh-install users still see something useful.
   - Title flips with the source: "Live now on your favorites" when
     pulling from favorites, "Continue with live TV" when pulling from
     recent-watch history. The label is always honest about the data.
   - "See all" link routes to the right collection page for the source
     (/workspace/global-favorites vs /workspace/global-recent).

2. Mixed Global Favorites rail removed from the dashboard
   - The rail had movies, series, live channels, and radio all sharing
     one row — different card formats fighting for visual attention.
     UX wiki principle: similar elements should look alike within a
     scanning unit.
   - Live favorites are promoted into the live rail above (with current
     EPG). The full mixed catalogue is still one click away at
     /workspace/global-favorites where the collection page can give it
     proper per-type filters.
   - Net dashboard density goes from 5 rails to 4 — closer to the
     streaming-app sweet spot.

i18n: new WORKSPACE.DASHBOARD.LIVE_CONTINUE key, translated across all
17 locales by per-locale agents (placeholder integrity verified).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-19 01:01:22 +02:00
1 parent 9bb41f59b0
commit fe849b2de2
22 files changed
+139 -30

No files matched your search

@@ -621,6 +621,69 @@ describe('DashboardDataService', () => {
expect(seriesPos?.positionSeconds).toBe(720);
});
it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => {
// Seed two favorites: a movie and a live channel.
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
{
id: 500,
category_id: 18,
title: 'Favorite Movie',
rating: '7.0',
added_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/fav-movie.png',
xtream_id: 5500,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
{
id: 501,
category_id: 19,
title: 'Favorite Channel HD',
rating: '0',
added_at: '2026-04-22T10:00:00.000Z',
poster_url: 'https://example.com/fav-channel.png',
xtream_id: 5501,
type: 'live',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
playlistsSignal.set([
...playlistsSignal(),
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 2,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
]);
await service.reloadGlobalFavorites();
const liveOnly = service.globalFavoriteLiveItems();
// Includes the Xtream "Favorite Channel HD" plus the two M3U live
// favorites already seeded by the default fixture.
expect(liveOnly.map((it) => it.title)).toEqual(
expect.arrayContaining([
'Favorite Channel HD',
'Channel One',
'Channel Two',
])
);
// Crucially, the movie favorite is excluded — the rail source must
// never leak VOD posters into the channel layout.
expect(liveOnly.map((it) => it.title)).not.toContain(
'Favorite Movie'
);
expect(
service.globalFavoriteLiveItems().every((it) => it.type === 'live')
).toBe(true);
});
it('returns null playback position for live channels (no position tracking in schema)', async () => {
// The default M3U playlist contains a live channel. Without
// reloading playback positions the lookup still gracefully returns
@@ -223,6 +223,14 @@ export class DashboardDataService {
this.globalRecentItems().filter((item) => item.type === 'live')
);
// Favorited live channels — the dashboard's "Live now" rail prefers this
// source so the EPG enrichment lands on the channels users actually care
// about. Falls back to globalRecentLiveItems when this is empty so a
// fresh-install user still sees something useful.
readonly globalFavoriteLiveItems = computed<DashboardFavoriteItem[]>(() =>
this.globalFavoriteItems().filter((item) => item.type === 'live')
);
// Playback positions, keyed by `${playlist_id}::${contentXtreamId}::${type}`.
// Loaded lazily on dashboard mount (and refreshed when recent items
// change) so the hero + Continue Watching cards can show "X min left"
@@ -131,13 +131,19 @@
/>
}
<!-- Live now on your favorites (channels you recently watched) -->
<!-- Live rail — favorited live channels first, falls back to
recently-watched live. Title flips based on source so the label
is always honest about what's rendered. -->
@if (liveOnFavoritesCardsEnriched().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.LIVE_RECENT' | translate"
[label]="liveRailTitleKey() | translate"
[items]="liveOnFavoritesCardsEnriched()"
[totalCount]="data.globalRecentLiveItems().length"
[seeAllLink]="['/workspace/global-recent']"
[totalCount]="liveRailTotalCount()"
[seeAllLink]="
liveRailSource() === 'favorites'
? ['/workspace/global-favorites']
: ['/workspace/global-recent']
"
layout="channel"
[testId]="'dashboard-live-recent-rail'"
/>
@@ -162,29 +168,11 @@
</section>
}
<!-- Favorites rail -->
@if (favoriteCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
[items]="favoriteCards()"
[totalCount]="data.globalFavoriteItems().length"
[seeAllLink]="['/workspace/global-favorites']"
[testId]="'dashboard-global-favorites-rail'"
/>
} @else if (data.globalFavoritesLoading()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
<!-- Mixed Global Favorites rail intentionally NOT rendered on the
dashboard. Live favorites are promoted into the live rail above
(with current EPG), and the full mixed catalogue lives on the
dedicated /workspace/global-favorites page where the collection
UI can give it the per-type filters it needs. -->
<!-- Sources rail -->
@if (sourceCards().length > 0) {
@@ -403,11 +403,43 @@ export class WorkspaceDashboardRailsComponent {
.map((item) => this.toRecentCard(item))
);
readonly liveOnFavoritesCards = computed<DashboardRailCard[]>(() =>
this.data
// Live rail source — favorited live channels take precedence so the EPG
// enrichment lands on the channels the user actually cares about. When
// no favorites exist (fresh install, or a user who hasn't starred any
// channel yet), fall back to recently-watched live so the rail still
// has something to show. `liveRailSource` drives the title swap.
readonly liveRailSource = computed<'favorites' | 'recent'>(() =>
this.data.globalFavoriteLiveItems().length > 0
? 'favorites'
: 'recent'
);
readonly liveOnFavoritesCards = computed<DashboardRailCard[]>(() => {
if (this.liveRailSource() === 'favorites') {
return this.data
.globalFavoriteLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item));
}
return this.data
.globalRecentLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item))
.map((item) => this.toRecentCard(item));
});
// Title key flips with the source — "Live now on your favorites" when
// we're rendering favorites, "Continue with live TV" when we're falling
// back to recently-watched. Always honest about what the user is seeing.
readonly liveRailTitleKey = computed(() =>
this.liveRailSource() === 'favorites'
? 'WORKSPACE.DASHBOARD.LIVE_RECENT'
: 'WORKSPACE.DASHBOARD.LIVE_CONTINUE'
);
readonly liveRailTotalCount = computed(() =>
this.liveRailSource() === 'favorites'
? this.data.globalFavoriteLiveItems().length
: this.data.globalRecentLiveItems().length
);
// Best-effort EPG lookup keyed by the channel's display name. This works