feat(web): add /requests user-facing request history

Renders the caller's Lidarr requests as rows with kind pill,
StatusPill, and per-status actions: Cancel on pending (which
calls cancelRequest then invalidates qk.myRequests()), Listen
link on completed (deepest match wins: track > album > artist),
admin notes on rejected. Empty state uses the voice-rule
"Nothing requested yet." copy. Shell nav gains /requests
between /discover and /playlists, visible to all authed users
since the view is per-user.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 22:24:17 -04:00
parent a7506d9413
commit ad904afaf6
4 changed files with 302 additions and 1 deletions
+1
View File
@@ -26,6 +26,7 @@
{ href: '/library/liked', label: 'Liked' },
{ href: '/search', label: 'Search' },
{ href: '/discover', label: 'Discover' },
{ href: '/requests', label: 'Requests' },
{ href: '/playlists', label: 'Playlists' },
{ href: '/settings', label: 'Settings' }
];
+2 -1
View File
@@ -28,11 +28,12 @@ describe('Shell', () => {
expect(screen.getByText('alice')).toBeInTheDocument();
});
test('renders nav items including Discover between Search and Playlists', () => {
test('renders nav items including Discover and Requests between Search and Playlists', () => {
render(Shell);
expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search');
expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover');
expect(screen.getByRole('link', { name: 'Requests' })).toHaveAttribute('href', '/requests');
expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists');
});
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import { Disc3, Album, Music2, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createMyRequestsQuery, cancelRequest } from '$lib/api/requests';
import { qk } from '$lib/api/queries';
import StatusPill from '$lib/components/StatusPill.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import type { LidarrRequest, LidarrRequestKind } from '$lib/api/types';
const queryStore = createMyRequestsQuery();
const query = $derived($queryStore);
const requests = $derived((query.data ?? []) as LidarrRequest[]);
const client = useQueryClient();
async function onCancel(id: string) {
try {
await cancelRequest(id);
await client.invalidateQueries({ queryKey: qk.myRequests() });
} catch {
// Swallow for v1; toast surface lands later. The row stays put so the
// user can retry — failed cancel doesn't lie about success.
}
}
function fallbackIcon(kind: LidarrRequestKind) {
if (kind === 'artist') return Disc3;
if (kind === 'album') return Album;
return Music2;
}
function rowTitle(r: LidarrRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? '—';
return r.track_title ?? '—';
}
// Keep the meta line short and readable. We always lead with the artist,
// then a relative-ish date — locale formatting is good enough for v1
// and keeps tests deterministic without pinning Intl.RelativeTimeFormat.
function rowMeta(r: LidarrRequest): string {
const when = new Date(r.requested_at).toLocaleDateString();
if (r.kind === 'artist') return `Requested ${when}`;
return `by ${r.artist_name} · ${when}`;
}
function listenHref(r: LidarrRequest): string | null {
if (r.matched_track_id) return `/tracks/${r.matched_track_id}`;
if (r.matched_album_id) return `/albums/${r.matched_album_id}`;
if (r.matched_artist_id) return `/artists/${r.matched_artist_id}`;
return null;
}
</script>
<div class="space-y-6">
<header class="space-y-1">
<h2 class="font-display text-2xl font-medium text-text-primary">
Your requests
</h2>
<p class="text-text-secondary">
What you've asked Minstrel to add to the library.
</p>
</header>
{#if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if query.isPending}
<p class="text-text-secondary">Reading the ledger…</p>
{:else if requests.length === 0}
<p class="text-text-secondary">Nothing requested yet.</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each requests as r (r.id)}
{@const Icon = fallbackIcon(r.kind)}
{@const href = listenHref(r)}
<li class="flex items-center gap-4 p-3" data-testid="request-row" data-status={r.status}>
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"
aria-hidden="true"
>
<Icon size={24} strokeWidth={1} class="text-text-muted" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class="kind-pill">{r.kind}</span>
<StatusPill status={r.status} />
</div>
<div class="truncate text-base font-medium text-text-primary">
{rowTitle(r)}
</div>
<div class="truncate text-sm text-text-secondary">
{rowMeta(r)}
</div>
{#if r.status === 'rejected' && r.notes}
<div class="text-sm text-text-secondary" data-testid="rejection-notes">
{r.notes}
</div>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
{#if r.status === 'pending'}
<button
type="button"
aria-label={`Cancel request for ${rowTitle(r)}`}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover"
onclick={() => onCancel(r.id)}
>
<X size={16} strokeWidth={1} /> Cancel
</button>
{:else if r.status === 'completed' && href}
<a
{href}
aria-label={`Listen to ${rowTitle(r)}`}
class="inline-flex items-center gap-1 text-sm text-accent hover:underline"
>
<Music2 size={16} strokeWidth={1} /> Listen
</a>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.kind-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 14px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
text-transform: capitalize;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import type { LidarrRequest } from '$lib/api/types';
// Capture invalidateQueries on the mocked QueryClient so the cancel test can
// assert against the same instance the page consumed.
const invalidateQueries = vi.fn().mockResolvedValue(undefined);
vi.mock('$lib/api/requests', () => ({
createMyRequestsQuery: vi.fn(),
cancelRequest: vi.fn().mockResolvedValue({ id: 'r1' })
}));
vi.mock('$lib/api/queries', () => ({
qk: {
myRequests: () => ['myRequests']
}
}));
vi.mock('$lib/api/client', () => ({
api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), del: vi.fn() }
}));
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
useQueryClient: () => ({ invalidateQueries })
};
});
import RequestsPage from './+page.svelte';
import { createMyRequestsQuery, cancelRequest } from '$lib/api/requests';
import { qk } from '$lib/api/queries';
const mockedCreateMyRequestsQuery = createMyRequestsQuery as ReturnType<typeof vi.fn>;
const mockedCancelRequest = cancelRequest as ReturnType<typeof vi.fn>;
function req(over: Partial<LidarrRequest> = {}): LidarrRequest {
return {
id: 'r1',
user_id: 'u1',
status: 'pending',
kind: 'album',
lidarr_artist_mbid: 'art-mbid',
lidarr_album_mbid: 'al-mbid',
lidarr_track_mbid: null,
artist_name: 'Boards of Canada',
album_title: 'Music Has The Right To Children',
track_title: null,
quality_profile_id: null,
root_folder_path: null,
decided_at: null,
decided_by: null,
notes: null,
completed_at: null,
matched_track_id: null,
matched_album_id: null,
matched_artist_id: null,
requested_at: '2026-04-28T12:00:00Z',
updated_at: '2026-04-28T12:00:00Z',
...over
};
}
afterEach(() => {
vi.clearAllMocks();
});
describe('Requests page', () => {
test('renders one row per request from the API', () => {
const rows: LidarrRequest[] = [
req({ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada' }),
req({ id: 'r2', kind: 'album', album_title: 'Geogaddi', status: 'completed' }),
req({ id: 'r3', kind: 'track', track_title: 'Roygbiv', status: 'rejected', notes: 'Already in library' })
];
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
render(RequestsPage);
expect(screen.getAllByTestId('request-row')).toHaveLength(3);
expect(screen.getByText('Boards of Canada')).toBeInTheDocument();
expect(screen.getByText('Geogaddi')).toBeInTheDocument();
expect(screen.getByText('Roygbiv')).toBeInTheDocument();
});
test('pending row exposes Cancel; clicking calls cancelRequest and invalidates myRequests', async () => {
const rows: LidarrRequest[] = [
req({ id: 'r-pending', status: 'pending', kind: 'album', album_title: 'Geogaddi' })
];
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
render(RequestsPage);
const cancelBtn = screen.getByRole('button', { name: /cancel request for geogaddi/i });
await fireEvent.click(cancelBtn);
await waitFor(() => {
expect(mockedCancelRequest).toHaveBeenCalledWith('r-pending');
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: qk.myRequests() });
});
});
test('completed row with matched_track_id renders Listen link to /tracks/<id>', () => {
const rows: LidarrRequest[] = [
req({
id: 'r-track',
status: 'completed',
kind: 'track',
track_title: 'Roygbiv',
matched_track_id: 'tr-9',
matched_album_id: 'al-9',
matched_artist_id: 'art-9'
})
];
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
render(RequestsPage);
const link = screen.getByRole('link', { name: /listen to roygbiv/i });
expect(link).toHaveAttribute('href', '/tracks/tr-9');
});
test('completed row with only matched_album_id renders Listen link to /albums/<id>', () => {
const rows: LidarrRequest[] = [
req({
id: 'r-album',
status: 'completed',
kind: 'album',
album_title: 'Geogaddi',
matched_track_id: null,
matched_album_id: 'al-7',
matched_artist_id: 'art-7'
})
];
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
render(RequestsPage);
const link = screen.getByRole('link', { name: /listen to geogaddi/i });
expect(link).toHaveAttribute('href', '/albums/al-7');
});
test('rejected row renders notes and hides Cancel and Listen', () => {
const rows: LidarrRequest[] = [
req({
id: 'r-rej',
status: 'rejected',
kind: 'album',
album_title: 'Geogaddi',
notes: 'Already kept in the library.'
})
];
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
render(RequestsPage);
expect(screen.getByTestId('rejection-notes')).toHaveTextContent('Already kept in the library.');
expect(screen.queryByRole('button', { name: /cancel request/i })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /listen to/i })).not.toBeInTheDocument();
});
test('empty list shows the voice-rule "Nothing requested yet." copy', () => {
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: [] }));
render(RequestsPage);
expect(screen.getByText(/nothing requested yet\./i)).toBeInTheDocument();
expect(screen.queryAllByTestId('request-row')).toHaveLength(0);
});
});