From ea17be9d458766cdf2feea71d5ca1fd8f11f7088 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Apr 2026 08:18:08 -0400 Subject: [PATCH] docs(plan): add web UI library views implementation plan 14 TDD tasks: API types, formatDuration + covers helpers, useDelayed rune, TanStack Query wrappers, 5 shared components (ArtistRow, AlbumCard, TrackRow, LibrarySkeleton, ApiErrorBanner), 3 page routes (/, /artists/:id, /albums/:id) with mocked-query integration tests, and a final verification + branch-finish step. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-04-23-web-ui-library-views.md | 1812 +++++++++++++++++ 1 file changed, 1812 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-web-ui-library-views.md diff --git a/docs/superpowers/plans/2026-04-23-web-ui-library-views.md b/docs/superpowers/plans/2026-04-23-web-ui-library-views.md new file mode 100644 index 00000000..ab1633f2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-web-ui-library-views.md @@ -0,0 +1,1812 @@ +# Web UI Library Views Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire up the three library browse routes — artists list at `/`, artist detail at `/artists/:id`, album detail at `/albums/:id` — consuming the existing `/api/artists*`, `/api/albums/:id`, and `/api/albums/:id/cover` endpoints, so an authenticated user can navigate the music library entirely in the SPA. + +**Architecture:** TanStack Query handles all fetches and caching via a thin `lib/api/queries.ts` helper. Each route is a tiny `+page.svelte` that imports a `create*Query` helper and delegates repeating visual units to small shared components (`ArtistRow`, `AlbumCard`, `TrackRow`, `LibrarySkeleton`, `ApiErrorBanner`). Loading/error/empty states follow the same pattern on all three routes. + +**Tech Stack:** SvelteKit 2 + Svelte 5 (runes), TypeScript, TanStack Query 5 (`@tanstack/svelte-query`), Vitest + jsdom + Testing Library, Tailwind. + +**Reference:** design spec at `docs/superpowers/specs/2026-04-23-web-ui-library-views-design.md`. + +--- + +## File Structure + +**New files under `web/`:** + +| File | Responsibility | +|---|---| +| `src/lib/api/types.ts` | Mirrors backend `internal/api/types.go` — `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page` | +| `src/lib/api/queries.ts` | `qk.*` key helpers and `createArtistsQuery`/`createArtistQuery`/`createAlbumQuery` wrappers | +| `src/lib/api/queries.test.ts` | Tests the pure `qk` key generators | +| `src/lib/media/covers.ts` | `FALLBACK_COVER` data URL + `coverUrl(id)` helper | +| `src/lib/media/duration.ts` | `formatDuration(seconds)` returning `mm:ss` or `h:mm:ss` | +| `src/lib/media/duration.test.ts` | Unit tests for `formatDuration` | +| `src/lib/utils/useDelayed.svelte.ts` | `useDelayed(getValue, delayMs)` rune helper for skeleton suppression | +| `src/lib/utils/useDelayed.test.ts` | Unit tests for `useDelayed` | +| `src/lib/components/ArtistRow.svelte` | Artists list row — avatar initial, name, album count, chevron, wrapping `` | +| `src/lib/components/ArtistRow.test.ts` | Component test | +| `src/lib/components/AlbumCard.svelte` | Album grid card — cover, title, year, wrapping `` | +| `src/lib/components/AlbumCard.test.ts` | Component test | +| `src/lib/components/TrackRow.svelte` | Album track row — number, title, duration | +| `src/lib/components/TrackRow.test.ts` | Component test | +| `src/lib/components/LibrarySkeleton.svelte` | Skeleton shimmer with variants `list`/`grid`/`album` | +| `src/lib/components/LibrarySkeleton.test.ts` | Component test | +| `src/lib/components/ApiErrorBanner.svelte` | Retry-capable error card | +| `src/lib/components/ApiErrorBanner.test.ts` | Component test | +| `src/test-utils/query.ts` | Shared `mockQuery` / `mockInfiniteQuery` factories for page tests | +| `src/routes/artists/[id]/+page.svelte` | Artist detail page | +| `src/routes/artists/[id]/artist.test.ts` | Page test (NOT `+page.test.ts` — route walker collision) | +| `src/routes/albums/[id]/+page.svelte` | Album detail page | +| `src/routes/albums/[id]/album.test.ts` | Page test | +| `src/routes/artists.test.ts` | Page test for the artists list (lives at `src/routes/`, not route-colocated in a `+` file) | + +**Modified files:** + +| File | Change | +|---|---| +| `src/routes/+page.svelte` | Replace the "Library" placeholder with the real artists list | + +--- + +## Task 1: API types mirroring `internal/api/types.go` + +**Files:** +- Create: `web/src/lib/api/types.ts` + +- [ ] **Step 1: Write `web/src/lib/api/types.ts`** + +```ts +// Mirrors internal/api/types.go. Kept minimal — only the shapes the SPA +// actually reads today (no LoginRequest/LoginResponse here; those live in +// client.ts alongside the auth plumbing). + +export type Page = { + items: T[]; + total: number; + limit: number; + offset: number; +}; + +export type ArtistRef = { + id: string; + name: string; + album_count: number; +}; + +export type AlbumRef = { + id: string; + title: string; + artist_id: string; + artist_name: string; + year?: number; + track_count: number; + duration_sec: number; + cover_url: string; +}; + +export type TrackRef = { + id: string; + title: string; + album_id: string; + album_title: string; + artist_id: string; + artist_name: string; + track_number?: number; + disc_number?: number; + duration_sec: number; + stream_url: string; +}; + +export type ArtistDetail = ArtistRef & { + albums: AlbumRef[]; +}; + +export type AlbumDetail = AlbumRef & { + tracks: TrackRef[]; +}; +``` + +- [ ] **Step 2: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/lib/api/types.ts +git commit -m "feat(web): add library API types mirroring internal/api/types.go" +``` + +--- + +## Task 2: `formatDuration` helper + tests + +**Files:** +- Create: `web/src/lib/media/duration.ts` +- Create: `web/src/lib/media/duration.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/media/duration.test.ts`: + +```ts +import { describe, expect, test } from 'vitest'; +import { formatDuration } from './duration'; + +describe('formatDuration', () => { + test('under a minute renders 0:SS', () => { + expect(formatDuration(42)).toBe('0:42'); + expect(formatDuration(5)).toBe('0:05'); + }); + + test('under an hour renders M:SS', () => { + expect(formatDuration(242)).toBe('4:02'); + expect(formatDuration(600)).toBe('10:00'); + }); + + test('at or above an hour renders H:MM:SS', () => { + expect(formatDuration(3600)).toBe('1:00:00'); + expect(formatDuration(3723)).toBe('1:02:03'); + expect(formatDuration(36000)).toBe('10:00:00'); + }); + + test('zero renders 0:00', () => { + expect(formatDuration(0)).toBe('0:00'); + }); + + test('negative values clamp to 0:00', () => { + expect(formatDuration(-1)).toBe('0:00'); + expect(formatDuration(-100)).toBe('0:00'); + }); + + test('fractional seconds floor', () => { + expect(formatDuration(61.9)).toBe('1:01'); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/media/duration.test.ts` +Expected: FAIL — `formatDuration` not exported. + +- [ ] **Step 3: Implement `web/src/lib/media/duration.ts`** + +```ts +// Formats a non-negative number of seconds as `mm:ss` when under an hour, +// `h:mm:ss` otherwise. Negative values clamp to zero (callers shouldn't +// pass them, but the UI shouldn't crash if they do). +export function formatDuration(seconds: number): string { + const total = Math.max(0, Math.floor(seconds)); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const ss = String(s).padStart(2, '0'); + if (h > 0) { + const mm = String(m).padStart(2, '0'); + return `${h}:${mm}:${ss}`; + } + return `${m}:${ss}`; +} +``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/media/duration.test.ts` +Expected: PASS — 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/media/duration.ts web/src/lib/media/duration.test.ts +git commit -m "feat(web): add formatDuration helper + +Renders seconds as mm:ss or h:mm:ss. Negative inputs clamp to 0:00 +so a bad backend value doesn't crash the UI." +``` + +--- + +## Task 3: `covers.ts` helper + +**Files:** +- Create: `web/src/lib/media/covers.ts` + +- [ ] **Step 1: Write `web/src/lib/media/covers.ts`** + +```ts +// Fallback cover — an inline SVG data URL showing a music-note glyph on the +// surface color. Used by AlbumCard's onerror handler when the real cover +// fails (e.g. scanner didn't pick up an embedded image and there's no +// sidecar). Data URL avoids an extra HTTP round-trip. +export const FALLBACK_COVER = + 'data:image/svg+xml;utf8,' + + encodeURIComponent( + ` + + + ` + ); + +export function coverUrl(albumId: string): string { + return `/api/albums/${albumId}/cover`; +} +``` + +- [ ] **Step 2: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/lib/media/covers.ts +git commit -m "feat(web): add cover URL helper + fallback SVG data URL" +``` + +--- + +## Task 4: `useDelayed` rune helper + +**Files:** +- Create: `web/src/lib/utils/useDelayed.svelte.ts` +- Create: `web/src/lib/utils/useDelayed.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/utils/useDelayed.test.ts`: + +```ts +import { describe, expect, test, vi } from 'vitest'; +import { flushSync } from 'svelte'; +import { useDelayed } from './useDelayed.svelte'; + +describe('useDelayed', () => { + test('value stays false until delay elapses while source is true', () => { + vi.useFakeTimers(); + try { + let source = $state(false); + let delayed!: { readonly value: boolean }; + const cleanup = $effect.root(() => { + delayed = useDelayed(() => source, 100); + }); + + expect(delayed.value).toBe(false); + + source = true; + flushSync(); + expect(delayed.value).toBe(false); + + vi.advanceTimersByTime(50); + expect(delayed.value).toBe(false); + + vi.advanceTimersByTime(60); + expect(delayed.value).toBe(true); + + cleanup(); + } finally { + vi.useRealTimers(); + } + }); + + test('source flipping false before delay cancels the pending timeout', () => { + vi.useFakeTimers(); + try { + let source = $state(false); + let delayed!: { readonly value: boolean }; + const cleanup = $effect.root(() => { + delayed = useDelayed(() => source, 100); + }); + + source = true; + flushSync(); + vi.advanceTimersByTime(40); + source = false; + flushSync(); + vi.advanceTimersByTime(200); + expect(delayed.value).toBe(false); + + cleanup(); + } finally { + vi.useRealTimers(); + } + }); + + test('value resets to false immediately when source becomes false', () => { + vi.useFakeTimers(); + try { + let source = $state(true); + let delayed!: { readonly value: boolean }; + const cleanup = $effect.root(() => { + delayed = useDelayed(() => source, 100); + }); + + vi.advanceTimersByTime(200); + expect(delayed.value).toBe(true); + + source = false; + flushSync(); + expect(delayed.value).toBe(false); + + cleanup(); + } finally { + vi.useRealTimers(); + } + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/utils/useDelayed.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `web/src/lib/utils/useDelayed.svelte.ts`** + +```ts +// Returns a rune-wrapped boolean that mirrors the source getter, but only +// flips to true after the source has been true for delayMs continuously. +// Flips back to false immediately when the source becomes false. +// +// Used to suppress flash-of-skeleton on cache-hit navigation: skeletons +// only render for loads that take longer than ~100ms. +export function useDelayed( + source: () => boolean, + delayMs = 100 +): { readonly value: boolean } { + let delayed = $state(false); + let timeout: ReturnType | null = null; + + $effect(() => { + const v = source(); + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (v) { + timeout = setTimeout(() => { + delayed = true; + timeout = null; + }, delayMs); + } else { + delayed = false; + } + return () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + }; + }); + + return { get value() { return delayed; } }; +} +``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/utils/useDelayed.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/utils/useDelayed.svelte.ts web/src/lib/utils/useDelayed.test.ts +git commit -m "feat(web): add useDelayed rune helper + +Boolean mirror that flips true only after the source stays true for +delayMs. Used to hide the skeleton on sub-100ms cache hits so +back-navigation feels instant." +``` + +--- + +## Task 5: `queries.ts` + key-generator tests + +**Files:** +- Create: `web/src/lib/api/queries.ts` +- Create: `web/src/lib/api/queries.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/api/queries.test.ts`: + +```ts +import { describe, expect, test } from 'vitest'; +import { qk } from './queries'; + +describe('qk key generators', () => { + test('artists key includes sort discriminator', () => { + expect(qk.artists('alpha')).toEqual(['artists', { sort: 'alpha' }]); + expect(qk.artists('newest')).toEqual(['artists', { sort: 'newest' }]); + }); + + test('artist key tuple', () => { + expect(qk.artist('abc')).toEqual(['artist', 'abc']); + }); + + test('album key tuple', () => { + expect(qk.album('xyz')).toEqual(['album', 'xyz']); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/api/queries.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `web/src/lib/api/queries.ts`** + +```ts +import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import type { ArtistRef, ArtistDetail, AlbumDetail, Page } from './types'; + +export type ArtistSort = 'alpha' | 'newest'; +export const ARTIST_PAGE_SIZE = 50; + +export const qk = { + artists: (sort: ArtistSort) => ['artists', { sort }] as const, + artist: (id: string) => ['artist', id] as const, + album: (id: string) => ['album', id] as const, +}; + +export function createArtistsQuery(sort: ArtistSort) { + return createInfiniteQuery({ + queryKey: qk.artists(sort), + queryFn: ({ pageParam = 0 }) => + api.get>( + `/api/artists?sort=${sort}&limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}` + ), + initialPageParam: 0, + getNextPageParam: (last) => { + const loaded = last.offset + last.items.length; + return loaded >= last.total ? undefined : loaded; + }, + }); +} + +export function createArtistQuery(id: string) { + return createQuery({ + queryKey: qk.artist(id), + queryFn: () => api.get(`/api/artists/${id}`), + }); +} + +export function createAlbumQuery(id: string) { + return createQuery({ + queryKey: qk.album(id), + queryFn: () => api.get(`/api/albums/${id}`), + }); +} +``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/api/queries.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/lib/api/queries.ts web/src/lib/api/queries.test.ts +git commit -m "feat(web): add TanStack Query helpers for library endpoints + +qk.{artists,artist,album} key generators and create*Query wrappers +with shared page size, sort-aware keys, and infinite-query plumbing +for /api/artists." +``` + +--- + +## Task 6: `ArtistRow` component + +**Files:** +- Create: `web/src/lib/components/ArtistRow.svelte` +- Create: `web/src/lib/components/ArtistRow.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/components/ArtistRow.test.ts`: + +```ts +import { describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import ArtistRow from './ArtistRow.svelte'; +import type { ArtistRef } from '$lib/api/types'; + +const artist: ArtistRef = { id: 'abc', name: 'alice', album_count: 12 }; + +describe('ArtistRow', () => { + test('renders initial, name, album count inside a link to /artists/:id', () => { + render(ArtistRow, { props: { artist } }); + + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/artists/abc'); + expect(link).toHaveTextContent('alice'); + expect(link).toHaveTextContent('12 albums'); + expect(link).toHaveTextContent('A'); // initial letter + }); + + test('singular "1 album" when album_count is 1', () => { + render(ArtistRow, { props: { artist: { ...artist, album_count: 1 } } }); + expect(screen.getByRole('link')).toHaveTextContent('1 album'); + }); + + test('empty name still renders a safe initial', () => { + render(ArtistRow, { props: { artist: { ...artist, name: '' } } }); + // No crash; initial container present but empty or a placeholder char. + expect(screen.getByRole('link')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/components/ArtistRow.test.ts` +Expected: FAIL — component not found. + +- [ ] **Step 3: Implement `web/src/lib/components/ArtistRow.svelte`** + +```svelte + + + + + {artist.name} + {countLabel} + + +``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/components/ArtistRow.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/components/ArtistRow.svelte web/src/lib/components/ArtistRow.test.ts +git commit -m "feat(web): add ArtistRow component + +One row in the artists list: avatar initial, name, album count, +chevron. Entire row is an for click + keyboard activation." +``` + +--- + +## Task 7: `AlbumCard` component + +**Files:** +- Create: `web/src/lib/components/AlbumCard.svelte` +- Create: `web/src/lib/components/AlbumCard.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/components/AlbumCard.test.ts`: + +```ts +import { describe, expect, test } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import AlbumCard from './AlbumCard.svelte'; +import type { AlbumRef } from '$lib/api/types'; +import { FALLBACK_COVER } from '$lib/media/covers'; + +const album: AlbumRef = { + id: 'xyz', + title: 'Kind of Blue', + artist_id: 'm-davis', + artist_name: 'Miles Davis', + year: 1959, + track_count: 5, + duration_sec: 2630, + cover_url: '/api/albums/xyz/cover', +}; + +describe('AlbumCard', () => { + test('renders cover, title, year inside a link to /albums/:id', () => { + render(AlbumCard, { props: { album } }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/albums/xyz'); + expect(link).toHaveTextContent('Kind of Blue'); + expect(link).toHaveTextContent('1959'); + + const img = screen.getByRole('img') as HTMLImageElement; + expect(img.src).toContain('/api/albums/xyz/cover'); + }); + + test('year is omitted when not present', () => { + render(AlbumCard, { props: { album: { ...album, year: undefined } } }); + // No crash; no year text. + expect(screen.queryByText(/1959/)).not.toBeInTheDocument(); + }); + + test('broken cover falls back to FALLBACK_COVER data URL', async () => { + render(AlbumCard, { props: { album } }); + const img = screen.getByRole('img') as HTMLImageElement; + await fireEvent.error(img); + expect(img.src).toBe(FALLBACK_COVER); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts` +Expected: FAIL — component not found. + +- [ ] **Step 3: Implement `web/src/lib/components/AlbumCard.svelte`** + +```svelte + + + + +
{album.title}
+ {#if album.year} +
{album.year}
+ {/if} +
+``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/components/AlbumCard.svelte web/src/lib/components/AlbumCard.test.ts +git commit -m "feat(web): add AlbumCard component + +Grid card used on the artist detail page. Cover image, title, year. +Broken covers swap to FALLBACK_COVER via onerror." +``` + +--- + +## Task 8: `TrackRow` component + +**Files:** +- Create: `web/src/lib/components/TrackRow.svelte` +- Create: `web/src/lib/components/TrackRow.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/components/TrackRow.test.ts`: + +```ts +import { describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import TrackRow from './TrackRow.svelte'; +import type { TrackRef } from '$lib/api/types'; + +const track: TrackRef = { + id: 't1', + title: 'So What', + album_id: 'xyz', + album_title: 'Kind of Blue', + artist_id: 'm-davis', + artist_name: 'Miles Davis', + track_number: 1, + disc_number: 1, + duration_sec: 545, + stream_url: '/api/tracks/t1/stream', +}; + +describe('TrackRow', () => { + test('renders track number, title, formatted duration', () => { + render(TrackRow, { props: { track } }); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('So What')).toBeInTheDocument(); + expect(screen.getByText('9:05')).toBeInTheDocument(); + }); + + test('track number falls back to dash when missing', () => { + render(TrackRow, { props: { track: { ...track, track_number: undefined } } }); + expect(screen.getByText('—')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts` +Expected: FAIL — component not found. + +- [ ] **Step 3: Implement `web/src/lib/components/TrackRow.svelte`** + +```svelte + + +
+ + {track.track_number ?? '—'} + + {track.title} + {formatDuration(track.duration_sec)} +
+``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts` +Expected: PASS — 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/components/TrackRow.svelte web/src/lib/components/TrackRow.test.ts +git commit -m "feat(web): add TrackRow component + +Non-interactive album track row with number, title, duration. +Player plan will add click-to-play." +``` + +--- + +## Task 9: `LibrarySkeleton` component + +**Files:** +- Create: `web/src/lib/components/LibrarySkeleton.svelte` +- Create: `web/src/lib/components/LibrarySkeleton.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/components/LibrarySkeleton.test.ts`: + +```ts +import { describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import LibrarySkeleton from './LibrarySkeleton.svelte'; + +describe('LibrarySkeleton', () => { + test('variant="list" renders count placeholder rows', () => { + render(LibrarySkeleton, { props: { variant: 'list', count: 5 } }); + const container = screen.getByTestId('skeleton-list'); + expect(container.querySelectorAll('[data-skeleton-row]').length).toBe(5); + }); + + test('variant="grid" renders count placeholder cards', () => { + render(LibrarySkeleton, { props: { variant: 'grid', count: 6 } }); + const container = screen.getByTestId('skeleton-grid'); + expect(container.querySelectorAll('[data-skeleton-card]').length).toBe(6); + }); + + test('variant="album" renders a hero placeholder with cover block + bars', () => { + render(LibrarySkeleton, { props: { variant: 'album' } }); + expect(screen.getByTestId('skeleton-album')).toBeInTheDocument(); + expect(screen.getByTestId('skeleton-album-cover')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/components/LibrarySkeleton.test.ts` +Expected: FAIL — component not found. + +- [ ] **Step 3: Implement `web/src/lib/components/LibrarySkeleton.svelte`** + +```svelte + + +{#if variant === 'list'} +
+ {#each Array.from({ length: count }) as _, i (i)} +
+ + + +
+ {/each} +
+{:else if variant === 'grid'} +
+ {#each Array.from({ length: count }) as _, i (i)} +
+
+
+
+
+ {/each} +
+{:else} +
+
+
+
+
+
+
+
+
+
+ {#each Array.from({ length: 10 }) as _, i (i)} +
+ {/each} +
+
+{/if} +``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/components/LibrarySkeleton.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/components/LibrarySkeleton.svelte web/src/lib/components/LibrarySkeleton.test.ts +git commit -m "feat(web): add LibrarySkeleton with list/grid/album variants + +Shimmer placeholders that mirror the layout of each library page so +the real content slides in without shifting." +``` + +--- + +## Task 10: `ApiErrorBanner` component + +**Files:** +- Create: `web/src/lib/components/ApiErrorBanner.svelte` +- Create: `web/src/lib/components/ApiErrorBanner.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/components/ApiErrorBanner.test.ts`: + +```ts +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import ApiErrorBanner from './ApiErrorBanner.svelte'; + +describe('ApiErrorBanner', () => { + test('renders error.message when present', () => { + render(ApiErrorBanner, { + props: { + error: { code: 'server_error', message: 'database down', status: 500 }, + onRetry: () => {} + } + }); + expect(screen.getByRole('alert')).toHaveTextContent('database down'); + }); + + test('falls back to generic text when error lacks message', () => { + render(ApiErrorBanner, { props: { error: undefined, onRetry: () => {} } }); + expect(screen.getByRole('alert')).toHaveTextContent(/something went wrong/i); + }); + + test('clicking the button calls onRetry', async () => { + const onRetry = vi.fn(); + render(ApiErrorBanner, { + props: { + error: { code: 'server_error', message: 'x', status: 500 }, + onRetry + } + }); + await fireEvent.click(screen.getByRole('button', { name: /try again/i })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/components/ApiErrorBanner.test.ts` +Expected: FAIL — component not found. + +- [ ] **Step 3: Implement `web/src/lib/components/ApiErrorBanner.svelte`** + +```svelte + + + +``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/components/ApiErrorBanner.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/components/ApiErrorBanner.svelte web/src/lib/components/ApiErrorBanner.test.ts +git commit -m "feat(web): add ApiErrorBanner retry-capable error card" +``` + +--- + +## Task 11: Artists list page at `/` + +**Files:** +- Modify: `web/src/routes/+page.svelte` +- Create: `web/src/test-utils/query.ts` +- Create: `web/src/routes/artists.test.ts` + +- [ ] **Step 1: Create `web/src/test-utils/query.ts`** + +```ts +import type { Page } from '$lib/api/types'; + +// Synthetic infinite-query object shape matching what the SPA reads from +// @tanstack/svelte-query. Enough surface area for component tests; +// real behavior is covered by the TanStack library's own tests. +export function mockInfiniteQuery(opts: { + pages?: Page[]; + isPending?: boolean; + isError?: boolean; + error?: unknown; + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + fetchNextPage?: () => void; + refetch?: () => void; +} = {}) { + return { + data: { pages: opts.pages ?? [] }, + isPending: opts.isPending ?? false, + isError: opts.isError ?? false, + error: opts.error, + hasNextPage: opts.hasNextPage ?? false, + isFetchingNextPage: opts.isFetchingNextPage ?? false, + fetchNextPage: opts.fetchNextPage ?? (() => {}), + refetch: opts.refetch ?? (() => {}) + }; +} + +export function mockQuery(opts: { + data?: T; + isPending?: boolean; + isError?: boolean; + error?: unknown; + refetch?: () => void; +} = {}) { + return { + data: opts.data, + isPending: opts.isPending ?? false, + isError: opts.isError ?? false, + error: opts.error, + refetch: opts.refetch ?? (() => {}) + }; +} +``` + +- [ ] **Step 2: Write the failing page test** + +Create `web/src/routes/artists.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { flushSync } from 'svelte'; +import { mockInfiniteQuery } from '../test-utils/query'; +import type { ArtistRef, Page } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/') })); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/api/queries', () => ({ + qk: { artists: (sort: string) => ['artists', { sort }] }, + createArtistsQuery: vi.fn() +})); + +import ArtistsPage from './+page.svelte'; +import { createArtistsQuery } from '$lib/api/queries'; +import { goto } from '$app/navigation'; + +function page(items: T[], total: number, offset = 0, limit = 50): Page { + return { items, total, offset, limit }; +} + +afterEach(() => { + vi.clearAllMocks(); + state.pageUrl = new URL('http://localhost/'); +}); + +describe('artists list page', () => { + test('renders a row per artist', () => { + const alice: ArtistRef = { id: 'a', name: 'Alice', album_count: 3 }; + const bob: ArtistRef = { id: 'b', name: 'Bob', album_count: 1 }; + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([alice, bob], 2)] }) + ); + render(ArtistsPage); + expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute('href', '/artists/a'); + expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/b'); + }); + + test('renders the total count from the first page', () => { + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 1247)] }) + ); + render(ArtistsPage); + expect(screen.getByText(/1247 artists/i)).toBeInTheDocument(); + }); + + test('Load more button calls fetchNextPage when hasNextPage', async () => { + const fetchNextPage = vi.fn(); + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ + pages: [page([], 100)], + hasNextPage: true, + fetchNextPage + }) + ); + render(ArtistsPage); + await fireEvent.click(screen.getByRole('button', { name: /load more/i })); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + test('"End of library" when hasNextPage is false and at least one page loaded', () => { + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) + ); + render(ArtistsPage); + expect(screen.getByText(/end of library/i)).toBeInTheDocument(); + }); + + test('empty total renders empty-library message', () => { + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + render(ArtistsPage); + expect(screen.getByText(/no artists yet/i)).toBeInTheDocument(); + }); + + test('changing the sort dropdown calls goto with ?sort=newest', async () => { + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + render(ArtistsPage); + const select = screen.getByLabelText(/sort/i) as HTMLSelectElement; + await fireEvent.change(select, { target: { value: 'newest' } }); + expect(goto).toHaveBeenCalledWith('?sort=newest', { replaceState: true }); + }); + + test('pending state renders the list skeleton after the useDelayed window', () => { + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ isPending: true }) + ); + vi.useFakeTimers(); + try { + render(ArtistsPage); + vi.advanceTimersByTime(200); + flushSync(); // flush the $effect that reads delayed.value + expect(screen.getByTestId('skeleton-list')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + test('error state renders the error banner with retry', async () => { + const refetch = vi.fn(); + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ + isError: true, + error: { code: 'server_error', message: 'db down', status: 500 }, + refetch + }) + ); + render(ArtistsPage); + expect(screen.getByRole('alert')).toHaveTextContent('db down'); + await fireEvent.click(screen.getByRole('button', { name: /try again/i })); + expect(refetch).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 3: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/routes/artists.test.ts` +Expected: FAIL — page still renders the placeholder, assertions don't match. + +- [ ] **Step 4: Replace `web/src/routes/+page.svelte`** + +```svelte + + +
+
+
+

Library

+ {#if !query.isPending && !query.isError} +

+ {total} {total === 1 ? 'artist' : 'artists'} +

+ {/if} +
+ +
+ + {#if query.isError} + + {:else if showSkeleton.value && artists.length === 0} + + {:else if !query.isPending && total === 0} +

+ No artists yet — scan a library folder via the server's config. +

+ {:else} +
+ {#each artists as a (a.id)} + + {/each} +
+ + {#if query.hasNextPage} + + {:else if artists.length > 0} +

End of library

+ {/if} + {/if} +
+``` + +- [ ] **Step 5: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/routes/artists.test.ts` +Expected: PASS — 8 tests. + +- [ ] **Step 6: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/routes/+page.svelte web/src/routes/artists.test.ts web/src/test-utils/query.ts +git commit -m "feat(web): replace / placeholder with real artists list + +Uses createArtistsQuery (infinite), URL-driven sort dropdown, Load +more pagination, delayed skeleton, and shared error banner. Also +introduces the test-utils/query.ts helpers for subsequent page tests." +``` + +--- + +## Task 12: Artist detail page at `/artists/:id` + +**Files:** +- Create: `web/src/routes/artists/[id]/+page.svelte` +- Create: `web/src/routes/artists/[id]/artist.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `web/src/routes/artists/[id]/artist.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { flushSync } from 'svelte'; +import { mockQuery } from '../../../test-utils/query'; +import type { ArtistDetail, AlbumRef } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record })); + +vi.mock('$app/state', () => ({ + page: { + get params() { return state.pageParams; }, + get url() { return new URL('http://localhost/artists/' + state.pageParams.id); } + } +})); + +vi.mock('$lib/api/queries', () => ({ + qk: { artist: (id: string) => ['artist', id] }, + createArtistQuery: vi.fn() +})); + +import ArtistPage from './+page.svelte'; +import { createArtistQuery } from '$lib/api/queries'; + +function album(id: string, title: string, year?: number): AlbumRef { + return { + id, title, + artist_id: 'abc', artist_name: 'Alice', + year, track_count: 10, duration_sec: 2400, + cover_url: `/api/albums/${id}/cover` + }; +} + +afterEach(() => { + vi.clearAllMocks(); + state.pageParams = { id: 'abc' }; +}); + +describe('artist detail page', () => { + test('renders artist name, subtitle, and one AlbumCard per album', () => { + const detail: ArtistDetail = { + id: 'abc', name: 'Alice', album_count: 2, + albums: [album('a1', 'First', 2020), album('a2', 'Second')] + }; + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + render(ArtistPage); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Alice'); + expect(screen.getByText(/2 albums/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /First/ })).toHaveAttribute('href', '/albums/a1'); + expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2'); + }); + + test('back link points to Library', () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ + data: { id: 'abc', name: 'Alice', album_count: 0, albums: [] } + })); + render(ArtistPage); + const back = screen.getByRole('link', { name: /library/i }); + expect(back).toHaveAttribute('href', '/'); + }); + + test('404 renders non-retryable "Artist not found" with back link', () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ + isError: true, + error: { code: 'not_found', message: 'nope', status: 404 } + })); + render(ArtistPage); + expect(screen.getByText(/artist not found/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); + }); + + test('non-404 error renders the retry banner', () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ + isError: true, + error: { code: 'server_error', message: 'boom', status: 500 } + })); + render(ArtistPage); + expect(screen.getByRole('alert')).toHaveTextContent('boom'); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + test('pending (after delay) renders the grid skeleton', () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true })); + vi.useFakeTimers(); + try { + render(ArtistPage); + vi.advanceTimersByTime(200); + flushSync(); + expect(screen.getByTestId('skeleton-grid')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/routes/artists/[id]/artist.test.ts` +Expected: FAIL — component not found. + +- [ ] **Step 3: Implement `web/src/routes/artists/[id]/+page.svelte`** + +```svelte + + +
+ ← Library + + {#if notFound} + + {:else if query.isError} + + {:else if showSkeleton.value && !query.data} + + {:else if query.data} + {@const detail = query.data} +
+

{detail.name}

+

+ {detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'} +

+
+ + {#if detail.albums.length === 0} +

This artist has no albums in the library.

+ {:else} +
+ {#each detail.albums as album (album.id)} + + {/each} +
+ {/if} + {/if} +
+``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/routes/artists/[id]/artist.test.ts` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/routes/artists/[id]/+page.svelte web/src/routes/artists/[id]/artist.test.ts +git commit -m "feat(web): add artist detail page at /artists/:id + +Renders the artist name + album count and a responsive AlbumCard grid +from the nested ArtistDetail response. 404 surfaces a distinct +non-retryable 'not found' state; other errors share the retry banner." +``` + +--- + +## Task 13: Album detail page at `/albums/:id` + +**Files:** +- Create: `web/src/routes/albums/[id]/+page.svelte` +- Create: `web/src/routes/albums/[id]/album.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `web/src/routes/albums/[id]/album.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { flushSync } from 'svelte'; +import { mockQuery } from '../../../test-utils/query'; +import type { AlbumDetail, TrackRef } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageParams: { id: 'xyz' } as Record })); + +vi.mock('$app/state', () => ({ + page: { + get params() { return state.pageParams; }, + get url() { return new URL('http://localhost/albums/' + state.pageParams.id); } + } +})); + +vi.mock('$lib/api/queries', () => ({ + qk: { album: (id: string) => ['album', id] }, + createAlbumQuery: vi.fn() +})); + +import AlbumPage from './+page.svelte'; +import { createAlbumQuery } from '$lib/api/queries'; + +function track(id: string, title: string, number: number, dur: number): TrackRef { + return { + id, title, + album_id: 'xyz', album_title: 'Kind of Blue', + artist_id: 'md', artist_name: 'Miles Davis', + track_number: number, disc_number: 1, + duration_sec: dur, + stream_url: `/api/tracks/${id}/stream` + }; +} + +afterEach(() => { + vi.clearAllMocks(); + state.pageParams = { id: 'xyz' }; +}); + +describe('album detail page', () => { + test('renders hero metadata + one TrackRow per track', () => { + const detail: AlbumDetail = { + id: 'xyz', title: 'Kind of Blue', + artist_id: 'md', artist_name: 'Miles Davis', + year: 1959, track_count: 2, duration_sec: 544 + 565, + cover_url: '/api/albums/xyz/cover', + tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)] + }; + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + render(AlbumPage); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Kind of Blue'); + expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md'); + expect(screen.getByText(/1959/)).toBeInTheDocument(); + expect(screen.getByText(/2 tracks/i)).toBeInTheDocument(); + + expect(screen.getByText('So What')).toBeInTheDocument(); + expect(screen.getByText('Freddie Freeloader')).toBeInTheDocument(); + + const img = screen.getByRole('img') as HTMLImageElement; + expect(img.src).toContain('/api/albums/xyz/cover'); + }); + + test('back link points to /artists/:artistId with the artist name', () => { + const detail: AlbumDetail = { + id: 'xyz', title: 'T', + artist_id: 'md', artist_name: 'Miles Davis', + track_count: 0, duration_sec: 0, + cover_url: '/api/albums/xyz/cover', + tracks: [] + }; + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + render(AlbumPage); + const back = screen.getByRole('link', { name: /Miles Davis/ }); + expect(back).toHaveAttribute('href', '/artists/md'); + }); + + test('404 renders non-retryable "Album not found" with back link', () => { + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ + isError: true, + error: { code: 'not_found', message: 'nope', status: 404 } + })); + render(AlbumPage); + expect(screen.getByText(/album not found/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); + }); + + test('non-404 error renders the retry banner', () => { + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ + isError: true, + error: { code: 'server_error', message: 'boom', status: 500 } + })); + render(AlbumPage); + expect(screen.getByRole('alert')).toHaveTextContent('boom'); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + test('pending (after delay) renders the album skeleton', () => { + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true })); + vi.useFakeTimers(); + try { + render(AlbumPage); + vi.advanceTimersByTime(200); + flushSync(); + expect(screen.getByTestId('skeleton-album')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/routes/albums/[id]/album.test.ts` +Expected: FAIL — component not found. + +- [ ] **Step 3: Implement `web/src/routes/albums/[id]/+page.svelte`** + +```svelte + + +
+ {#if notFound} + + {:else if query.isError} + + {:else if showSkeleton.value && !query.data} + + {:else if query.data} + {@const album = query.data} + + ← {album.artist_name} + + +
+ +
+

{album.title}

+

+ {album.artist_name} +

+ {#if album.year} +

{album.year}

+ {/if} +

+ {album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} + · {formatDuration(album.duration_sec)} +

+
+
+ + {#if album.tracks.length === 0} +

This album has no tracks.

+ {:else} +
+ {#each album.tracks as track (track.id)} + + {/each} +
+ {/if} + {/if} +
+``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/routes/albums/[id]/album.test.ts` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/routes/albums/[id]/+page.svelte web/src/routes/albums/[id]/album.test.ts +git commit -m "feat(web): add album detail page at /albums/:id + +Hero with cover + title + linked artist + metadata; TrackRow list +below. Back link targets the artist page (not Library) so drilling +up feels correct. 404 renders a distinct non-retryable state." +``` + +--- + +## Task 14: Final verification + branch finish + +**Files:** none (verification only). + +- [ ] **Step 1: Full Go suite (sanity — backend untouched)** + +Run: `go test -short -race ./...` +Expected: PASS. + +- [ ] **Step 2: Go linter** + +Run: `golangci-lint run ./...` +Expected: no issues. + +- [ ] **Step 3: Web check + full tests + build** + +Run: `cd web && npm run check && npm test && npm run build` +Expected: check 0 errors / 0 warnings; all vitest files pass; build emits `web/build/`. + +Run: `git checkout -- web/build/index.html` +Expected: committed placeholder restored. + +- [ ] **Step 4: Docker build smoke** + +Run: `docker build -t minstrel:library-smoke .` +Expected: image builds. + +Run: `docker run --rm --entrypoint /bin/sh minstrel:library-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` +Expected: `ok`. + +- [ ] **Step 5: Local end-to-end manual check** + +Bring up the stack: + +```bash +docker compose up --build -d +``` + +In a browser: + +1. Sign in with the seeded admin user. +2. `/` renders the artists list with counts and "Load more" if more than 50 artists exist. +3. Change sort dropdown → URL shows `?sort=newest`, list reorders. +4. Click an artist → albums grid renders with covers (some may show the fallback glyph — that's expected for albums without embedded cover art). +5. Click an album → hero + track list. Duration format looks right (`3:42` for short, `1:02:03` for long). +6. Click the back link on the album page → returns to the artist (not to `/`). +7. Visit `/artists/00000000-0000-0000-0000-000000000000` → "Artist not found" card. +8. Visit `/albums/00000000-0000-0000-0000-000000000000` → "Album not found" card. +9. Refresh `/artists/` → Shell renders without login flash; data refetches and renders. + +Tear down: + +```bash +docker compose down +``` + +- [ ] **Step 6: Finish the branch** + +Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. + +--- + +## Self-Review Notes + +**Spec coverage check:** +- API types → Task 1 +- `queries.ts` helpers + query-key convention → Task 5 +- Cover URL + fallback → Task 3 +- Duration formatter → Task 2 +- Delayed skeleton helper → Task 4 +- ArtistRow / AlbumCard / TrackRow / LibrarySkeleton / ApiErrorBanner → Tasks 6–10 +- Test-utils mocks → Task 11 (introduced alongside the first page that needs them) +- Artists list page (replace `/` placeholder) → Task 11 +- Artist detail page → Task 12 +- Album detail page → Task 13 +- Final verification + branch finish → Task 14 + +All spec sections map to tasks. + +**Type consistency:** +- `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page` — defined in Task 1, used identically in Tasks 5–13. +- `ArtistSort` — exported from `queries.ts` (Task 5), consumed in Task 11. +- `ApiError` — re-imported from `$lib/api/client` (auth plan) in Tasks 10, 12, 13. +- `createArtistsQuery` / `createArtistQuery` / `createAlbumQuery` signatures consistent between Task 5 and consuming tasks. +- `qk.artists(sort)` / `qk.artist(id)` / `qk.album(id)` — mocked-out in page tests exactly as defined. +- `FALLBACK_COVER` — defined in Task 3, used in Tasks 7 (AlbumCard) and 13 (album page hero). +- `formatDuration(seconds)` — defined in Task 2, used in Task 8 (TrackRow test and component) and Task 13. +- `useDelayed(getValue, delayMs?)` — defined in Task 4, used in Tasks 11, 12, 13. + +**Filename hazards addressed:** No page test is named `+page.test.ts` (auth plan's lesson). Route-colocated tests use `artist.test.ts` / `album.test.ts`; the artists list test lives at `src/routes/artists.test.ts` (outside the `+` route-walker).