Files
minstrel/docs/superpowers/plans/2026-04-23-web-ui-library-views.md
T
bvandeusen f9b02c4051 docs(plan): Task 13 — match the type-safety fixes applied in Task 12
page.params.id is string|undefined in SvelteKit types; use ?? ''.
TanStack Query's error is typed as Error; cast through unknown to ApiError.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:13:20 -04:00

1816 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<T>` |
| `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.svelte.test.ts` | Unit tests for `useDelayed` |
| `src/lib/components/ArtistRow.svelte` | Artists list row — avatar initial, name, album count, chevron, wrapping `<a>` |
| `src/lib/components/ArtistRow.test.ts` | Component test |
| `src/lib/components/AlbumCard.svelte` | Album grid card — cover, title, year, wrapping `<a>` |
| `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<T> = {
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(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#1f1f23"/>
<path d="M40 30v35a7 7 0 1 1-7-7 7 7 0 0 1 3 .7V35h22v8h-18z" fill="#6b6b70"/>
</svg>`
);
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.svelte.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `web/src/lib/utils/useDelayed.svelte.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);
});
flushSync(); // run the initial $effect so the setTimeout is scheduled
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.svelte.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<typeof setTimeout> | 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.svelte.test.ts`
Expected: PASS — 3 tests.
- [ ] **Step 5: Commit**
```bash
git add web/src/lib/utils/useDelayed.svelte.ts web/src/lib/utils/useDelayed.svelte.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<Page<ArtistRef>>(
`/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<ArtistDetail>(`/api/artists/${id}`),
});
}
export function createAlbumQuery(id: string) {
return createQuery({
queryKey: qk.album(id),
queryFn: () => api.get<AlbumDetail>(`/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
<script lang="ts">
import type { ArtistRef } from '$lib/api/types';
let { artist }: { artist: ArtistRef } = $props();
const initial = artist.name ? artist.name.charAt(0).toUpperCase() : '';
const countLabel = artist.album_count === 1 ? '1 album' : `${artist.album_count} albums`;
</script>
<a
href={`/artists/${artist.id}`}
class="flex items-center gap-3 border-b border-border px-3 py-3 hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
>
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface font-semibold"
aria-hidden="true"
>
{initial}
</span>
<span class="flex-1 truncate">{artist.name}</span>
<span class="text-sm text-text-secondary">{countLabel}</span>
<span class="text-text-secondary" aria-hidden="true"></span>
</a>
```
- [ ] **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 <a> 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
<script lang="ts">
import type { AlbumRef } from '$lib/api/types';
import { FALLBACK_COVER } from '$lib/media/covers';
let { album }: { album: AlbumRef } = $props();
function onImgError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
</script>
<a
href={`/albums/${album.id}`}
class="group block rounded focus-visible:ring-2 focus-visible:ring-accent"
>
<img
src={album.cover_url}
alt=""
class="aspect-square w-full rounded object-cover transition-transform group-hover:scale-[1.03]"
loading="lazy"
onerror={onImgError}
/>
<div class="mt-2 truncate text-sm font-medium">{album.title}</div>
{#if album.year}
<div class="text-xs text-text-secondary">{album.year}</div>
{/if}
</a>
```
- [ ] **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
<script lang="ts">
import type { TrackRef } from '$lib/api/types';
import { formatDuration } from '$lib/media/duration';
let { track }: { track: TrackRef } = $props();
</script>
<div class="grid grid-cols-[32px_1fr_auto] items-center gap-4 px-3 py-2 text-sm odd:bg-surface/50">
<span class="text-right tabular-nums text-text-secondary">
{track.track_number ?? '—'}
</span>
<span class="truncate">{track.title}</span>
<span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
</div>
```
- [ ] **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
<script lang="ts">
type Variant = 'list' | 'grid' | 'album';
let { variant, count = 12 }: { variant: Variant; count?: number } = $props();
// Common shimmer class applied to every placeholder block.
const shimmer = 'animate-pulse bg-surface';
</script>
{#if variant === 'list'}
<div data-testid="skeleton-list" class="divide-y divide-border">
{#each Array.from({ length: count }) as _, i (i)}
<div data-skeleton-row class="flex items-center gap-3 px-3 py-3">
<span class="h-10 w-10 rounded-full {shimmer}"></span>
<span class="h-4 flex-1 rounded {shimmer}"></span>
<span class="h-3 w-20 rounded {shimmer}"></span>
</div>
{/each}
</div>
{:else if variant === 'grid'}
<div
data-testid="skeleton-grid"
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
>
{#each Array.from({ length: count }) as _, i (i)}
<div data-skeleton-card>
<div class="aspect-square w-full rounded {shimmer}"></div>
<div class="mt-2 h-4 w-3/4 rounded {shimmer}"></div>
<div class="mt-1 h-3 w-1/3 rounded {shimmer}"></div>
</div>
{/each}
</div>
{:else}
<div data-testid="skeleton-album" class="space-y-6">
<div class="flex flex-col gap-4 md:flex-row md:items-end">
<div data-skeleton-album-cover class="h-60 w-60 rounded {shimmer}"></div>
<div class="flex-1 space-y-3">
<div class="h-8 w-3/4 rounded {shimmer}"></div>
<div class="h-4 w-1/3 rounded {shimmer}"></div>
<div class="h-4 w-1/4 rounded {shimmer}"></div>
</div>
</div>
<div class="space-y-2">
{#each Array.from({ length: 10 }) as _, i (i)}
<div class="h-6 w-full rounded {shimmer}"></div>
{/each}
</div>
</div>
{/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
<script lang="ts">
import type { ApiError } from '$lib/api/client';
let {
error,
onRetry
}: { error: unknown; onRetry: () => void } = $props();
const apiErr = error as ApiError | undefined;
const message = apiErr?.message ?? 'Something went wrong.';
</script>
<div role="alert" class="rounded border border-danger/40 bg-surface p-4">
<p class="text-text-primary">{message}</p>
<button
type="button"
class="mt-2 rounded bg-accent px-3 py-1 text-sm text-background"
onclick={onRetry}
>
Try again
</button>
</div>
```
- [ ] **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<T>(opts: {
pages?: Page<T>[];
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<T>(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<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([], 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<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({
pages: [page<ArtistRef>([], 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<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([{ 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<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([], 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<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([], 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<typeof vi.fn>).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<typeof vi.fn>).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
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { createArtistsQuery, type ArtistSort } from '$lib/api/queries';
import ArtistRow from '$lib/components/ArtistRow.svelte';
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
const sort: ArtistSort = $derived(
(page.url.searchParams.get('sort') as ArtistSort) === 'newest' ? 'newest' : 'alpha'
);
const query = $derived(createArtistsQuery(sort));
// Flattened artist list from all loaded pages.
const artists = $derived(
query.data?.pages?.flatMap((p) => p.items) ?? []
);
const total = $derived(query.data?.pages?.[0]?.total ?? 0);
const showSkeleton = useDelayed(() => query.isPending);
function onSortChange(e: Event) {
const value = (e.currentTarget as HTMLSelectElement).value as ArtistSort;
goto(`?sort=${value}`, { replaceState: true });
}
</script>
<div class="space-y-4">
<header class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Library</h1>
{#if !query.isPending && !query.isError}
<p class="text-sm text-text-secondary">
{total} {total === 1 ? 'artist' : 'artists'}
</p>
{/if}
</div>
<label class="flex items-center gap-2 text-sm">
<span class="text-text-secondary">Sort</span>
<select
value={sort}
onchange={onSortChange}
class="rounded border border-border bg-surface px-2 py-1"
>
<option value="alpha">AZ</option>
<option value="newest">Newest</option>
</select>
</label>
</header>
{#if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && artists.length === 0}
<LibrarySkeleton variant="list" count={12} />
{:else if !query.isPending && total === 0}
<p class="text-text-secondary">
No artists yet — scan a library folder via the server's config.
</p>
{:else}
<div>
{#each artists as a (a.id)}
<ArtistRow artist={a} />
{/each}
</div>
{#if query.hasNextPage}
<button
type="button"
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
disabled={query.isFetchingNextPage}
onclick={() => query.fetchNextPage()}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{:else if artists.length > 0}
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
{/if}
{/if}
</div>
```
- [ ] **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<string, string> }));
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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
<script lang="ts">
import { page } from '$app/state';
import { createArtistQuery } from '$lib/api/queries';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
import type { ApiError } from '$lib/api/client';
const id = $derived(page.params.id);
const queryStore = $derived(createArtistQuery(id));
const query = $derived($queryStore);
const showSkeleton = useDelayed(() => query.isPending);
const notFound = $derived(
query.isError && (query.error as ApiError | undefined)?.code === 'not_found'
);
</script>
<div class="space-y-4">
<a href="/" class="text-sm text-text-secondary hover:text-text-primary">← Library</a>
{#if notFound}
<div role="alert" class="rounded border border-border bg-surface p-4">
<p>Artist not found.</p>
<a href="/" class="mt-2 inline-block text-sm text-accent">Back to Library</a>
</div>
{:else if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && !query.data}
<LibrarySkeleton variant="grid" count={12} />
{:else if query.data}
{@const detail = query.data}
<header>
<h1 class="text-2xl font-semibold">{detail.name}</h1>
<p class="text-sm text-text-secondary">
{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}
</p>
</header>
{#if detail.albums.length === 0}
<p class="text-text-secondary">This artist has no albums in the library.</p>
{:else}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each detail.albums as album (album.id)}
<AlbumCard {album} />
{/each}
</div>
{/if}
{/if}
</div>
```
- [ ] **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<string, string> }));
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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
<script lang="ts">
import { page } from '$app/state';
import { createAlbumQuery } from '$lib/api/queries';
import TrackRow from '$lib/components/TrackRow.svelte';
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER } from '$lib/media/covers';
import type { ApiError } from '$lib/api/client';
const id = $derived(page.params.id ?? '');
const queryStore = $derived(createAlbumQuery(id));
const query = $derived($queryStore);
const showSkeleton = useDelayed(() => query.isPending);
const notFound = $derived(
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
);
function onCoverError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
</script>
<div class="space-y-6">
{#if notFound}
<div role="alert" class="rounded border border-border bg-surface p-4">
<p>Album not found.</p>
<a href="/" class="mt-2 inline-block text-sm text-accent">Back to Library</a>
</div>
{:else if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && !query.data}
<LibrarySkeleton variant="album" />
{:else if query.data}
{@const album = query.data}
<a
href={`/artists/${album.artist_id}`}
class="text-sm text-text-secondary hover:text-text-primary"
>
{album.artist_name}
</a>
<header class="flex flex-col gap-4 md:flex-row md:items-end">
<img
src={album.cover_url}
alt=""
class="h-60 w-60 rounded object-cover"
loading="lazy"
onerror={onCoverError}
/>
<div class="flex-1 space-y-1">
<h1 class="text-3xl font-semibold">{album.title}</h1>
<p>
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
</p>
{#if album.year}
<p class="text-sm text-text-secondary">{album.year}</p>
{/if}
<p class="text-sm text-text-secondary">
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
· {formatDuration(album.duration_sec)}
</p>
</div>
</header>
{#if album.tracks.length === 0}
<p class="text-text-secondary">This album has no tracks.</p>
{:else}
<div class="overflow-hidden rounded border border-border">
{#each album.tracks as track (track.id)}
<TrackRow {track} />
{/each}
</div>
{/if}
{/if}
</div>
```
- [ ] **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/<real-id>` → 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 610
- 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<T>` — defined in Task 1, used identically in Tasks 513.
- `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).