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"