From 5698cfe8e4bdda67fed039bd85154c12b1d3fdd5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 4 May 2026 18:34:03 -0400 Subject: [PATCH] =?UTF-8?q?feat(web/m7-352):=20home=20Playlists=20row=20?= =?UTF-8?q?=E2=80=94=20For-You=20+=20Songs-like=20+=20user=20playlists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- web/src/routes/+page.svelte | 85 ++++++++++++++++++++++++++ web/src/routes/page.test.ts | 117 ++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 web/src/routes/page.test.ts diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 80c500e0..1ecdfb48 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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; + }); {pageTitle('Home')}
+ +
+ + {#snippet item(rowItem: PlaylistRowItem)} +
+ {#if rowItem.kind === 'real'} + + {:else} + + {/if} +
+ {/snippet} +
+
+ {#if query.isError} {:else if showSkeleton.value && !data} diff --git a/web/src/routes/page.test.ts b/web/src/routes/page.test.ts new file mode 100644 index 00000000..b489a916 --- /dev/null +++ b/web/src/routes/page.test.ts @@ -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; + 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).mockImplementation(() => + readable({ data: emptyPlaylistsResponse, isPending: false, isError: false }) + ); + (createSystemPlaylistsStatusQuery as unknown as ReturnType).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).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).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 + }); +});