Files
yellowjacket/frontend/src/store/search-store.ts
T
logan bddfd37a5c
Build & publish Arch package / arch-package (push) Successful in 2m3s
CI / check (push) Canceled after 1m17s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
feat(track-list): show Album by default, and search smart playlists
H-15: the default columns were track, artist and duration, so a library
manager with duplicate detection could not tell its own duplicate
fixtures apart by eye. Album is a default now, in Go and in the
frontend fallback — both, because a fresh install persists the Go list
and the UI renders the TS one until the config arrives.

It does not deliver the finding's stated benefit, and that is worth
recording: the three `Tideline / Aurora Fields / 00:06` rows are
duplicates of the same album, so they read identically with an Album
column too. What tells them apart is the duplicate-detection feature or
a file path column, not this. Album is still the right default for
every other row in the list.

smart-playlist-details joins search-store's scope map. Checked before
adding, as asked: it reads searchCtrl.term in getVisibleTracks and
prints the term in the page, so the header box was disabled and
unlabelled on a view that filters as you type — the fix is a scope
entry, not a disabled state with a reason.
2026-08-12 12:38:36 -04:00

127 lines
4.1 KiB
TypeScript

/**
* What the header search box searches, per view.
*
* The box is **view-scoped by decision** (plan 007, Decisions 2), and
* H-10 is that it never said so: it sits in the global header,
* placeheld "Search…", and typing `tide` on Playlists answered "No
* playlists match your search" with three *Tideline* tracks in the
* library. The scope was always real; the copy is what was missing.
*
* A view absent from this map is one with nothing of its own to
* search. The box keeps its slot there and is disabled, rather than
* being removed — its appearing and disappearing is what made the
* whole header change shape between pages.
*/
const SEARCH_SCOPES: Record<string, string> = {
tracks: 'tracks',
albums: 'albums',
artists: 'artists',
genres: 'genres',
playlists: 'playlists',
'playlist-details': 'tracks in this playlist',
// Its sibling was in this map and it was not, so the box went
// disabled and unlabelled on a view that filters on the term all
// the same — typing narrowed the list under a placeholder saying
// there was nothing to search. Checked before adding: it reads
// `searchCtrl.term` in `getVisibleTracks` and says so in the page.
'smart-playlist-details': 'tracks in this smart playlist',
};
/**
* Views with a search of their own in the page. The header box points
* at it rather than pretending to be it.
*/
const OWN_SEARCH_VIEWS: Record<string, string> = {
explore: 'Search the catalog in the page',
};
type Subscriber = () => void;
class SearchStore {
private term = '';
private currentView = 'tracks';
private subscribers = new Set<Subscriber>();
// ===================================================================
// SEARCH TERM
// ===================================================================
getTerm(): string {
return this.term;
}
setTerm(term: string): void {
if (term === this.term) return;
this.term = term;
this.notify();
}
// ===================================================================
// CURRENT VIEW
// ===================================================================
getCurrentView(): string {
return this.currentView;
}
setCurrentView(view: string): void {
if (view === this.currentView) return;
this.currentView = view;
this.notify();
}
isSearchableView(): boolean {
return this.currentView in SEARCH_SCOPES;
}
/** What this view searches, e.g. `albums`. Empty if it does not. */
scopeLabel(): string {
return SEARCH_SCOPES[this.currentView] ?? '';
}
/**
* What the box should say when it cannot be used here: either that
* the page has its own search, or that there is nothing to search.
*/
disabledReason(): string {
if (this.isSearchableView()) return '';
return (
OWN_SEARCH_VIEWS[this.currentView] ??
'Nothing to search on this page'
);
}
// ===================================================================
// SUBSCRIPTION SYSTEM
// ===================================================================
subscribe(callback: Subscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
/**
* Deliberately *not* microtask-coalesced, unlike every other store
* here (perf.p3 asks for it, and it is wrong about this one).
*
* Two reasons. Deferring makes an unsubscribe that happens
* synchronously after a set drop the notification entirely, which
* is a semantic change, not an optimisation — `view-stores.test.ts`
* pins both halves. And this store is on the keystroke path, where
* the batching the audit says would hide the cost is Lit's, not
* ours: the `requestUpdate()`s are already coalesced one layer
* down, so the microtask buys nothing and costs a frame of term
* staleness in the one place a frame is visible.
*/
private notify(): void {
this.subscribers.forEach((callback) => callback());
}
}
// Singleton instance.
export const searchStore = new SearchStore();