feat(library): genre + year quick-jumps on album and artist detail — #367
Last bullet of #367. From an album you like, one click to everything else from that year or in that genre. Year was free — AlbumRef already carried it. Genre was not: AlbumDetail is AlbumRef + tracks and neither carried genre, because genre lives on TRACKS. So both detail responses gained a derived `genres` array, computed from the entity's tracks rather than stored, since an album's tracks can legitimately disagree about genre. Split and trimmed identically to the browse index. That's the invariant this whole task turned on: if the chip's matching diverged from the index's splitting, a chip would lead to a page that doesn't contain the album you clicked from. No year link on artist detail. An artist spans many years, so a single one would be a lie about the discography — genres only there. Genre lookup failure is logged and degrades to no chips rather than failing the request; a navigation nicety must not 404 a detail page that otherwise loaded. `genres` is always an array at JSON, never null, matching how every other list field in this package is emitted. ## Type widening, and the TypeScript version of a lesson from earlier today Adding a required field to AlbumDetail/ArtistDetail breaks every typed fixture that constructs one. Six of them across three test files. That's the same shape as the Go signature changes that cost three CI rounds in #2453 — change a type, then go find everything that builds it — so I searched for the constructions before pushing instead of after. All six updated. Tests: the encoded href for a slash-bearing genre ("Rock/Pop" → ?g=Rock%2FPop), the year href, and the no-tags case rendering no chips at all. gofmt verified clean via docker rather than guessed.
This commit is contained in:
@@ -126,8 +126,31 @@
|
||||
<p>
|
||||
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
|
||||
</p>
|
||||
<!-- Year and genre are quick-jumps into the browse axes (#367): from
|
||||
an album you like, one click to everything else from that year or
|
||||
in that genre. -->
|
||||
{#if album.year}
|
||||
<p class="text-sm text-text-secondary">{album.year}</p>
|
||||
<p class="text-sm">
|
||||
<a href={`/library/years?y=${album.year}`} class="text-accent hover:underline">
|
||||
{album.year}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
{#if album.genres?.length}
|
||||
<ul class="flex flex-wrap gap-1.5">
|
||||
{#each album.genres as genre (genre)}
|
||||
<li>
|
||||
<a
|
||||
href={`/library/genres?g=${encodeURIComponent(genre)}`}
|
||||
class="inline-block rounded-full border border-border px-2.5 py-0.5 text-xs
|
||||
text-text-secondary hover:bg-surface-hover hover:text-text-primary
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{genre}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="text-sm text-text-secondary">
|
||||
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
|
||||
|
||||
@@ -49,7 +49,8 @@ describe('album detail page', () => {
|
||||
year: 1959, track_count: 2, duration_sec: 544 + 565,
|
||||
cover_url: '/api/albums/xyz/cover',
|
||||
cover_art_source: null,
|
||||
tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)]
|
||||
tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)],
|
||||
genres: ['Jazz']
|
||||
};
|
||||
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(AlbumPage);
|
||||
@@ -77,7 +78,8 @@ describe('album detail page', () => {
|
||||
track_count: 0, duration_sec: 0,
|
||||
cover_url: '/api/albums/xyz/cover',
|
||||
cover_art_source: null,
|
||||
tracks: []
|
||||
tracks: [],
|
||||
genres: []
|
||||
};
|
||||
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(AlbumPage);
|
||||
@@ -119,4 +121,51 @@ describe('album detail page', () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// Quick-jumps into the browse axes (#367). The genre href must be encoded:
|
||||
// a slash-bearing tag is why browse selection lives in the query string.
|
||||
test('year and genre are quick-jump links into the browse axes', () => {
|
||||
const detail: AlbumDetail = {
|
||||
id: 'xyz', title: 'Kind of Blue', sort_title: 'Kind of Blue',
|
||||
artist_id: 'md', artist_name: 'Miles Davis',
|
||||
year: 1959, track_count: 0, duration_sec: 0,
|
||||
cover_url: '/api/albums/xyz/cover',
|
||||
cover_art_source: null,
|
||||
tracks: [],
|
||||
genres: ['Jazz', 'Rock/Pop']
|
||||
};
|
||||
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(AlbumPage);
|
||||
|
||||
expect(screen.getByRole('link', { name: '1959' })).toHaveAttribute(
|
||||
'href',
|
||||
'/library/years?y=1959'
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'Jazz' })).toHaveAttribute(
|
||||
'href',
|
||||
'/library/genres?g=Jazz'
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'Rock/Pop' })).toHaveAttribute(
|
||||
'href',
|
||||
'/library/genres?g=Rock%2FPop'
|
||||
);
|
||||
});
|
||||
|
||||
test('no genre chips when the album carries no genre tags', () => {
|
||||
const detail: AlbumDetail = {
|
||||
id: 'xyz', title: 'Untagged', sort_title: 'Untagged',
|
||||
artist_id: 'md', artist_name: 'Miles Davis',
|
||||
track_count: 0, duration_sec: 0,
|
||||
cover_url: '/api/albums/xyz/cover',
|
||||
cover_art_source: null,
|
||||
tracks: [],
|
||||
genres: []
|
||||
};
|
||||
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(AlbumPage);
|
||||
|
||||
expect(screen.queryByRole('link', { name: /library\/genres/ })).toBeNull();
|
||||
// No year on this fixture either, so no year jump.
|
||||
expect(screen.queryByRole('link', { name: /^\d{4}$/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,24 @@
|
||||
<p class="text-sm text-text-secondary">
|
||||
{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}
|
||||
</p>
|
||||
<!-- Genre quick-jumps (#367). No year link here: an artist spans many,
|
||||
so a single year would be a lie about the discography. -->
|
||||
{#if detail.genres?.length}
|
||||
<ul class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#each detail.genres as genre (genre)}
|
||||
<li>
|
||||
<a
|
||||
href={`/library/genres?g=${encodeURIComponent(genre)}`}
|
||||
class="inline-block rounded-full border border-border px-2.5 py-0.5 text-xs
|
||||
text-text-secondary hover:bg-surface-hover hover:text-text-primary
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{genre}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -57,7 +57,8 @@ describe('artist detail page', () => {
|
||||
test('renders artist name, subtitle, and one AlbumCard per album', () => {
|
||||
const detail: ArtistDetail = {
|
||||
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 2, cover_url: '',
|
||||
albums: [album('a1', 'First', 2020), album('a2', 'Second')]
|
||||
albums: [album('a1', 'First', 2020), album('a2', 'Second')],
|
||||
genres: []
|
||||
};
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(ArtistPage);
|
||||
@@ -70,7 +71,8 @@ describe('artist detail page', () => {
|
||||
test('renders top-tracks panel and similar-artists strip when present', () => {
|
||||
const detail: ArtistDetail = {
|
||||
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
|
||||
albums: [album('a1', 'First', 2020)]
|
||||
albums: [album('a1', 'First', 2020)],
|
||||
genres: []
|
||||
};
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||
|
||||
Reference in New Issue
Block a user