feat(home): populate the home page with start-listening shelves

The sidebar had a Home item that fell through to "Coming soon". What
was missing was not another view of the library — four of those exist,
sorted and complete — but the opposite: a complete, sorted library is
exactly what gives you nothing to play, because every entry point into
it is alphabetical and identical every time you open the app.

So a shelf is a *reason*, not a filter. Each one answers a different
question you might be asking when you do not know what you want (what
was I listening to, what is new, what do I keep coming back to, what
have I forgotten, what fits, what would I never pick myself) and each
says which question it answered — a row of covers with no explanation
is just another grid.

Two consequences run through it. Shelves are built from what the user
actually did — play counts, last played, import order — with random
sampling only where there is no signal to use, so randomness is the
fallback rather than the design. And a shelf with nothing behind it is
omitted instead of rendered empty: a fresh library legitimately gets
three, and an empty row labelled "on repeat" would be a lie.

The queries return album ids and nothing else, joined back to
GetAllAlbumsWithDetails in Go, so the album projection keeps having one
definition rather than one per shelf.
This commit is contained in:
2026-08-11 01:15:34 -04:00
parent 62bb40fc4d
commit ff687f0bd9
12 changed files with 1838 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
/**
* The home page renders whatever `backend/home` decided, and nothing
* else: the shelves, their stated reasons, and two things a cover can
* do. So these tests are about the contract between the two — that a
* shelf's reason is displayed rather than swallowed, that a card opens
* the album it names, and that a play button plays it instead of
* opening it.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/home-view/home-view';
import { stub, calls, lastArgs, stubFailure } from '@test/support/harness';
import { fixture, shadow, shadowAll, texts, text } from '@test/support/render';
function album(id: number, name: string, artist = 'Artist') {
return {
ID: id,
Name: name,
ArtistName: artist,
MBID: '',
Year: 2000,
ReleaseYear: 2000,
CoverArtPath: '',
CoverArtSmall: '',
CoverArtMedium: '',
CoverArtLarge: '',
ArtistMBID: '',
};
}
const SHELVES = [
{
id: 'recently-played',
kind: 'recently-played',
title: 'Pick up where you left off',
subtitle: 'The last albums you played',
albums: [album(1, 'Kid A'), album(2, 'Amnesiac')],
},
{
id: 'genre-Doom Jazz',
kind: 'genre',
title: 'Doom Jazz',
subtitle: 'Because your library is full of it',
albums: [album(3, 'Black Ships')],
},
];
describe('home view', () => {
beforeEach(() => {
stub('home.Service.GetShelves', SHELVES);
stub('library.Library.GetAlbumTracks', [
{ FilePath: '/music/1.mp3' },
{ FilePath: '/music/2.mp3' },
]);
});
it('renders a row per shelf, each with the reason it exists', async () => {
const el = await fixture('home-view');
await el.updateComplete;
expect(texts(el, '.shelf-title')).toEqual([
'Pick up where you left off',
'Doom Jazz',
]);
// The subtitle is the whole difference between a shelf and a grid.
expect(texts(el, '.shelf-sub')).toEqual([
'The last albums you played',
'Because your library is full of it',
]);
});
it('keys each row by the kind the backend assigned', async () => {
const el = await fixture('home-view');
await el.updateComplete;
expect(
shadowAll(el, '.shelf').map((s) => s.getAttribute('data-kind')),
).toEqual(['recently-played', 'genre']);
});
it('opens the album a card names, by local id', async () => {
const el = await fixture('home-view');
await el.updateComplete;
const seen: unknown[] = [];
el.addEventListener('navigate', (e) => seen.push((e as CustomEvent).detail));
shadow<HTMLElement>(el, '.card')!.click();
expect(seen).toEqual([
{
view: 'explore-album-details',
releaseGroupMBID: '',
albumName: 'Kid A',
artistName: 'Artist',
localAlbumId: 1,
},
]);
});
it('plays the album from the play button without navigating', async () => {
const el = await fixture('home-view');
await el.updateComplete;
const seen: unknown[] = [];
el.addEventListener('navigate', (e) => seen.push(e));
shadow<HTMLElement>(el, '.play')!.click();
await new Promise((r) => setTimeout(r, 0));
expect(seen).toEqual([]);
expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1]);
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
['/music/1.mp3', '/music/2.mp3'],
0,
true,
]);
});
it('says so rather than rendering an empty page when there is nothing', async () => {
stub('home.Service.GetShelves', []);
const el = await fixture('home-view');
await el.updateComplete;
expect(text(el, '.empty')).toContain('Nothing to suggest yet');
});
it('reports a backend failure instead of pretending the library is empty', async () => {
stubFailure('home.Service.GetShelves');
const el = await fixture('home-view');
await el.updateComplete;
await el.updateComplete;
expect(text(el, '.empty')).toContain('Could not read your library');
});
it('rebuilds on demand', async () => {
const el = await fixture('home-view');
await el.updateComplete;
const before = calls('home.Service.GetShelves').length;
shadow<HTMLElement>(el, 'wa-button')!.click();
await el.updateComplete;
expect(calls('home.Service.GetShelves').length).toBe(before + 1);
});
});