Genre index: sort A–Z, and repair casing damage at scan time #124
@@ -30,6 +30,31 @@
|
|||||||
return genres.filter((g: GenreCount) => g.genre.toLowerCase().includes(q));
|
return genres.filter((g: GenreCount) => g.genre.toLowerCase().includes(q));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Count-first by default because that's what the server returns and it's the
|
||||||
|
// right default: the head of this list is genuinely where you're going. But a
|
||||||
|
// real library runs to several hundred genres with a long tail of one-offs
|
||||||
|
// (391 / ~3.7 per track on the operator's), and at that size "I know roughly
|
||||||
|
// what it's called" needs A-Z as much as filtering does.
|
||||||
|
//
|
||||||
|
// View state only, not a query parameter — same as `filter` above. Neither is
|
||||||
|
// worth making shareable, and putting one in the URL and not the other would
|
||||||
|
// be the inconsistent choice.
|
||||||
|
let sortMode = $state<'count' | 'name'>('count');
|
||||||
|
|
||||||
|
const visibleGenres = $derived.by(() => {
|
||||||
|
// Copy before sorting. With no filter applied `filteredGenres` IS the array
|
||||||
|
// held by the query cache, and Array.sort mutates in place — sorting it
|
||||||
|
// directly would reorder cached data for every other consumer.
|
||||||
|
const list = [...filteredGenres];
|
||||||
|
if (sortMode === 'name') {
|
||||||
|
// sensitivity 'base' so case and accents don't split neighbours apart.
|
||||||
|
return list.sort((a: GenreCount, b: GenreCount) =>
|
||||||
|
a.genre.localeCompare(b.genre, undefined, { sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list; // server order: count DESC, then name
|
||||||
|
});
|
||||||
|
|
||||||
let albums = $state<AlbumRef[]>([]);
|
let albums = $state<AlbumRef[]>([]);
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -153,12 +178,30 @@
|
|||||||
<h1 class="font-display text-2xl font-medium text-text-primary">Genres</h1>
|
<h1 class="font-display text-2xl font-medium text-text-primary">Genres</h1>
|
||||||
{#if !index.isPending && !index.isError}
|
{#if !index.isPending && !index.isError}
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{genres.length} {genres.length === 1 ? 'genre' : 'genres'}, straight from your file tags
|
{#if filter.trim()}
|
||||||
|
{visibleGenres.length} of {genres.length} genres
|
||||||
|
{:else}
|
||||||
|
{genres.length} {genres.length === 1 ? 'genre' : 'genres'}, straight from your file tags
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if genres.length > 0}
|
{#if genres.length > 0}
|
||||||
<QuickFilter bind:value={filter} placeholder="Filter genres" />
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<QuickFilter bind:value={filter} placeholder="Filter genres" />
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||||
|
Sort
|
||||||
|
<select
|
||||||
|
bind:value={sortMode}
|
||||||
|
aria-label="Sort genres"
|
||||||
|
class="rounded border border-border bg-surface px-2 py-1 text-sm text-text-primary
|
||||||
|
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<option value="count">Most tracks</option>
|
||||||
|
<option value="name">A–Z</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -180,16 +223,17 @@
|
|||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
{:else if filter.trim() && filteredGenres.length === 0}
|
{:else if filter.trim() && visibleGenres.length === 0}
|
||||||
<p class="text-text-secondary">
|
<p class="text-text-secondary">
|
||||||
No genres match <span class="font-medium">'{filter.trim()}'</span>.
|
No genres match <span class="font-medium">'{filter.trim()}'</span>.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Ordered by track count, not alphabetically: raw tags carry a long
|
<!-- Defaults to track count rather than alphabetical: raw tags carry a
|
||||||
tail of one-offs, so alphabetical would bury the handful of genres
|
long tail of one-offs, so A-Z would bury the handful of genres you
|
||||||
you actually have a library's worth of. -->
|
actually have a library's worth of. The Sort control lets you ask for
|
||||||
|
A-Z when you already know roughly what you're looking for. -->
|
||||||
<ul class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
<ul class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each filteredGenres as g (g.genre)}
|
{#each visibleGenres as g (g.genre)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={genreHref(g.genre)}
|
href={genreHref(g.genre)}
|
||||||
|
|||||||
@@ -98,6 +98,57 @@ describe('/library/genres index', () => {
|
|||||||
expect(screen.getByRole('link', { name: /^rock 2$/ })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: /^rock 2$/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2468: the operator's real library is 391 genres at ~3.7 per track, so the
|
||||||
|
// count-first default buries anything you can already name. A–Z is the ask.
|
||||||
|
test('A–Z sort reorders the list without mutating the source array', async () => {
|
||||||
|
const data = [
|
||||||
|
{ genre: 'Rock', track_count: 8974 },
|
||||||
|
{ genre: 'Ambient', track_count: 646 },
|
||||||
|
{ genre: 'jazz', track_count: 1406 }
|
||||||
|
];
|
||||||
|
asMock(createGenresQuery).mockReturnValue(mockQuery({ data }));
|
||||||
|
render(GenresPage);
|
||||||
|
|
||||||
|
const order = () =>
|
||||||
|
screen.getAllByRole('link').map((a) => a.textContent?.trim().split(/\s+/)[0]);
|
||||||
|
|
||||||
|
// Count mode PRESERVES the server's order rather than re-sorting client
|
||||||
|
// side — the server already returns count DESC, name ASC, and duplicating
|
||||||
|
// that here would be two orderings to keep in step. The fixture is
|
||||||
|
// deliberately not in count order so this asserts pass-through, not luck.
|
||||||
|
expect(order()).toEqual(['Rock', 'Ambient', 'jazz']);
|
||||||
|
|
||||||
|
await fireEvent.change(screen.getByLabelText('Sort genres'), { target: { value: 'name' } });
|
||||||
|
|
||||||
|
// Case-insensitive, so 'jazz' sorts between Ambient and Rock rather than
|
||||||
|
// after both.
|
||||||
|
expect(order()).toEqual(['Ambient', 'jazz', 'Rock']);
|
||||||
|
|
||||||
|
// The query cache's own array must not have been reordered in place —
|
||||||
|
// Array.sort mutates, and with no filter applied the derived list IS that
|
||||||
|
// array.
|
||||||
|
expect(data.map((d) => d.genre)).toEqual(['Rock', 'Ambient', 'jazz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filtering reports the visible subset against the total', async () => {
|
||||||
|
asMock(createGenresQuery).mockReturnValue(
|
||||||
|
mockQuery({
|
||||||
|
data: [
|
||||||
|
{ genre: 'Rock', track_count: 10 },
|
||||||
|
{ genre: 'Ska Punk', track_count: 5 },
|
||||||
|
{ genre: 'Jazz', track_count: 3 }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(GenresPage);
|
||||||
|
expect(screen.getByText(/3 genres, straight from your file tags/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await fireEvent.input(screen.getByLabelText('Filter genres'), { target: { value: 'punk' } });
|
||||||
|
|
||||||
|
// QuickFilter debounces by 120ms.
|
||||||
|
await waitFor(() => expect(screen.getByText('1 of 3 genres')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
test('empty library explains where genres come from', () => {
|
test('empty library explains where genres come from', () => {
|
||||||
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
|
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||||
render(GenresPage);
|
render(GenresPage);
|
||||||
|
|||||||
Reference in New Issue
Block a user