feat(web/m7-352): home Playlists row — For-You + Songs-like + user playlists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 18:34:03 -04:00
parent b8bb1c1fa7
commit 5698cfe8e4
2 changed files with 202 additions and 0 deletions
+85
View File
@@ -7,6 +7,11 @@
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
import PlaylistCard from '$lib/components/PlaylistCard.svelte';
import PlaylistPlaceholderCard from '$lib/components/PlaylistPlaceholderCard.svelte';
import { createPlaylistsQuery } from '$lib/api/playlists';
import { createSystemPlaylistsStatusQuery } from '$lib/api/me';
import type { Playlist } from '$lib/api/types';
const queryStore = createHomeQuery();
const query = $derived($queryStore);
@@ -21,11 +26,91 @@
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
const systemPlaylistsStore = $derived(createPlaylistsQuery('system'));
const userPlaylistsStore = $derived(createPlaylistsQuery('user'));
const systemStatusStore = $derived(createSystemPlaylistsStatusQuery());
const systemPlaylistsQ = $derived($systemPlaylistsStore);
const userPlaylistsQ = $derived($userPlaylistsStore);
const systemStatusQ = $derived($systemStatusStore);
const forYouPlaylist = $derived(
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'for_you') ?? null
);
const songsLikePlaylists = $derived(
(systemPlaylistsQ.data?.owned ?? [])
.filter((p) => p.system_variant === 'songs_like_artist')
.slice(0, 3)
);
const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []);
type PlaceholderVariant = 'building' | 'failed' | 'pending' | 'seed-needed';
function placeholderVariant(slot: 'for-you' | 'songs-like'): PlaceholderVariant {
const s = systemStatusQ.data;
if (!s) return 'pending';
if (s.in_flight) return 'building';
if (s.last_error) return 'failed';
if (slot === 'songs-like' && s.last_run_at) return 'seed-needed';
return 'pending';
}
type PlaylistRowItem =
| { kind: 'real'; playlist: Playlist }
| { kind: 'placeholder'; label: string; variant: PlaceholderVariant };
const playlistsRow = $derived.by((): PlaylistRowItem[] => {
const out: PlaylistRowItem[] = [];
// Slot 1: For-You (real or placeholder).
if (forYouPlaylist) {
out.push({ kind: 'real', playlist: forYouPlaylist });
} else {
out.push({ kind: 'placeholder', label: 'For You', variant: placeholderVariant('for-you') });
}
// Slots 2-4: Songs-like (real first, padded with placeholders).
for (let i = 0; i < 3; i++) {
if (songsLikePlaylists[i]) {
out.push({ kind: 'real', playlist: songsLikePlaylists[i] });
} else {
out.push({ kind: 'placeholder', label: 'Songs like…', variant: placeholderVariant('songs-like') });
}
}
// User playlists trail (server returns most-recently-updated first).
for (const p of userPlaylists) {
out.push({ kind: 'real', playlist: p });
}
return out;
});
</script>
<svelte:head><title>{pageTitle('Home')}</title></svelte:head>
<div class="space-y-8">
<!-- Playlists: For-You + Songs-like + user playlists, single horizontal row -->
<section class="space-y-3">
<HorizontalScrollRow
rows={[playlistsRow]}
title="Playlists"
ariaLabel="Playlists"
>
{#snippet item(rowItem: PlaylistRowItem)}
<div class="w-44">
{#if rowItem.kind === 'real'}
<PlaylistCard playlist={rowItem.playlist} />
{:else}
<PlaylistPlaceholderCard label={rowItem.label} variant={rowItem.variant} />
{/if}
</div>
{/snippet}
</HorizontalScrollRow>
</section>
{#if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && !data}
+117
View File
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import type { Playlist } from '$lib/api/types';
// Mock all the queries the home page constructs.
vi.mock('$lib/api/home', () => ({
createHomeQuery: () =>
readable({
data: {
recently_added_albums: [],
most_played_tracks: [],
rediscover_albums: [],
rediscover_artists: [],
last_played_artists: []
},
isPending: false,
isError: false,
refetch: vi.fn()
})
}));
vi.mock('$lib/api/playlists', () => ({
createPlaylistsQuery: vi.fn()
}));
vi.mock('$lib/api/me', () => ({
createSystemPlaylistsStatusQuery: vi.fn()
}));
// LikeButton imports — needed because the home renders cards that may use it.
vi.mock('$lib/api/likes', () => ({
createLikedIdsQuery: () =>
readable({ data: { track_ids: [], album_ids: [], artist_ids: [] }, isPending: false, isError: false }),
likeEntity: vi.fn().mockResolvedValue(undefined),
unlikeEntity: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
import Page from './+page.svelte';
import { createPlaylistsQuery } from '$lib/api/playlists';
import { createSystemPlaylistsStatusQuery } from '$lib/api/me';
const emptyPlaylistsResponse = { owned: [], public: [] };
beforeEach(() => {
(createPlaylistsQuery as unknown as ReturnType<typeof vi.fn>).mockImplementation(() =>
readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
);
(createSystemPlaylistsStatusQuery as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
readable({
data: { in_flight: false, last_run_at: null, last_error: null },
isPending: false,
isError: false
})
);
});
afterEach(() => vi.clearAllMocks());
describe('home Playlists section', () => {
test('renders 4 placeholder cards when no playlists exist', () => {
render(Page);
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
expect(placeholders).toHaveLength(4);
});
test('building status sets variant=building on placeholders', () => {
(createSystemPlaylistsStatusQuery as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
readable({
data: { in_flight: true, last_run_at: null, last_error: null },
isPending: false,
isError: false
})
);
const { container } = render(Page);
const buildingPlaceholders = container.querySelectorAll('[data-variant="building"]');
expect(buildingPlaceholders.length).toBeGreaterThan(0);
});
test('renders For-You playlist + 3 placeholders when only for_you exists', () => {
const forYou: Playlist = {
id: 'fy',
user_id: 'u1',
owner_username: 'u1',
name: 'For You',
description: '',
is_public: false,
kind: 'system',
system_variant: 'for_you',
seed_artist_id: null,
cover_url: '',
track_count: 25,
duration_sec: 1500,
created_at: '2026-05-04T00:00:00Z',
updated_at: '2026-05-04T00:00:00Z'
};
(createPlaylistsQuery as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(kind?: string) =>
kind === 'system'
? readable({
data: { owned: [forYou], public: [] },
isPending: false,
isError: false
})
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
);
render(Page);
expect(screen.getByText('For You')).toBeInTheDocument();
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
expect(placeholders).toHaveLength(3); // 3 songs-like slots
});
});