From 4509f740f835071bd8cf6c34f922531692434456 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 7 Aug 2026 13:11:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20sort=20the=20genre=20index=20A?= =?UTF-8?q?=E2=80=93Z=20as=20well=20as=20by=20count=20=E2=80=94=20#2468?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator decision: the taxonomy this task proposed is cancelled. With their repaired library measured — 391 genres, 90,774 tag applications over ~24,185 tracks, so ~3.7 genres per track — multi-membership already puts each track under everything it claims, and grouping would add nothing while destroying real specificity (Neurofunk, Wassoulou, Soukous are not noise). The task's premise was also wrong. It argued from case variants, "Alt. Rock" abbreviations and a junk tail; none exist. That apparent mess was the scanner welding multi-value tags (#2499) plus ghost rows from deleted files (#2523), both ours, both now fixed. A category system would have papered over both. So the ask reduces to sorting and search. The search box already existed (QuickFilter, with its own no-matches state), so this adds only the sort: count-first by default — the server's order, and the right default since the head is where you're going — or A–Z for when you can already name the thing but can't find it among 391 rows. The filtered count now reads "12 of 391" so a filter's effect is visible. Client-side only: /api/library/genres is unpaged and already returns the whole set (~12KB), so neither control needs a round trip or a server change. Two things the tests pin down: - The sort COPIES before sorting. With no filter applied the derived list is the very array held by the query cache, and Array.sort mutates in place — sorting it directly would reorder cached data under every other consumer. - Count mode passes the server's order through rather than re-sorting. The fixture is deliberately not in count order so the test asserts pass-through instead of coincidence. Verified locally: svelte-check 0 errors, 110 files / 788 tests. --- web/src/routes/library/genres/+page.svelte | 58 +++++++++++++++++--- web/src/routes/library/genres/genres.test.ts | 51 +++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/web/src/routes/library/genres/+page.svelte b/web/src/routes/library/genres/+page.svelte index 069de967..70f2423d 100644 --- a/web/src/routes/library/genres/+page.svelte +++ b/web/src/routes/library/genres/+page.svelte @@ -30,6 +30,31 @@ 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([]); let total = $state(0); let loading = $state(false); @@ -153,12 +178,30 @@

Genres

{#if !index.isPending && !index.isError}

- {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}

{/if} {#if genres.length > 0} - +
+ + +
{/if} @@ -180,16 +223,17 @@ {/snippet} - {:else if filter.trim() && filteredGenres.length === 0} + {:else if filter.trim() && visibleGenres.length === 0}

No genres match '{filter.trim()}'.

{:else} - +