feat(web): order satisfied titles last
ci / web (push) Successful in 42s
e2e / e2e (push) Failing after 1m12s
ci / rust (push) Successful in 1m18s
ci / image (push) Successful in 3m41s

The library hid satisfied titles behind a toggle, so a finished show was
gone from the view that lists it. Ordering says the same thing without
taking anything away.
This commit is contained in:
Miguel Palhas
2026-09-06 16:39:19 +01:00
parent 8699572be8
commit 77ac295bdf
4 changed files with 41 additions and 45 deletions
+5 -4
View File
@@ -51,8 +51,9 @@ export async function fetchLibrary(): Promise<LibraryOutcome> {
}
/**
* §4.2: the default list shows `airing` and `incomplete`; everything else
* collapses behind one toggle. A satisfied title leaves by itself.
* §4.2: `airing` and `incomplete` sort to the top of the list; everything
* else is satisfied and sinks below it. A satisfied title steps aside by
* itself, and is still there to click.
*/
export function seriesNeedsAttention(series: LibrarySeries): boolean {
return series.status === "airing" || series.status === "incomplete";
@@ -76,8 +77,8 @@ export function seriesStatusChipRedundant(series: {
}
/**
* The movie analogue of §4.2's default set: wanted and not yet on disk.
* Available and unwanted titles are satisfied and collapse with the rest.
* The movie analogue of §4.2's ordering: wanted and not yet on disk sorts
* first. Available and unwanted titles are satisfied and sink with the rest.
*/
export function movieNeedsAttention(movie: LibraryMovie): boolean {
return movie.wanted && movie.state !== "available";
+32 -29
View File
@@ -3651,6 +3651,14 @@ function loadLibraryShape(): LibraryShape {
}
}
/**
* Stable partition: the items that still want something keep their order and
* come first, the satisfied ones keep theirs and follow.
*/
function attentionFirst<T>(items: T[], wants: (item: T) => boolean): T[] {
return [...items.filter(wants), ...items.filter((item) => !wants(item))];
}
function libraryMain(
movieDetail: MovieView,
seriesDetail: SeriesView,
@@ -3659,7 +3667,6 @@ function libraryMain(
const view = must<HTMLElement>("#library");
const deck = must<HTMLElement>("#deck");
const summary = must<HTMLElement>("#library-summary");
const toggle = must<HTMLButtonElement>("#library-toggle");
const status = must<HTMLElement>("#library-status");
const gridBtn = must<HTMLButtonElement>("#library-view-grid");
const listBtn = must<HTMLButtonElement>("#library-view-list");
@@ -3677,8 +3684,6 @@ function libraryMain(
},
};
// §4.2: ONE toggle for everything the default view leaves out
let showAll = false;
// §9.6 library view: the poster grid is default, per-person, localStorage
let shape = loadLibraryShape();
let data: { series: LibrarySeries[]; movies: LibraryMovie[] } | null = null;
@@ -3721,7 +3726,6 @@ function libraryMain(
group.rows.replaceChildren();
}
summary.textContent = "";
toggle.hidden = true;
}
function renderGroup<T>(group: LibraryGroup, items: T[], build: (item: T) => HTMLLIElement) {
@@ -3777,17 +3781,13 @@ function libraryMain(
return;
}
const total = library.series.length + library.movies.length;
const satisfied =
library.series.filter((series) => !seriesNeedsAttention(series)).length +
library.movies.filter((movie) => !movieNeedsAttention(movie)).length;
summary.textContent = total === 0 ? "" : `${total} ${total === 1 ? "title" : "titles"}`;
toggle.hidden = satisfied === 0;
toggle.textContent = `${showAll ? "hide" : "show"} ${satisfied} satisfied`;
toggle.setAttribute("aria-expanded", String(showAll));
const seriesShown = showAll ? library.series : library.series.filter(seriesNeedsAttention);
const moviesShown = showAll ? library.movies : library.movies.filter(movieNeedsAttention);
// §4.2: nothing is hidden. Satisfied titles sort last so what still
// wants something is what the eye lands on first.
const seriesShown = attentionFirst(library.series, seriesNeedsAttention);
const moviesShown = attentionFirst(library.movies, movieNeedsAttention);
const openSeries = (id: number, origin: HTMLElement) => {
navigate({ kind: "series", seriesId: id });
@@ -3801,16 +3801,26 @@ function libraryMain(
if (shape === "grid") {
groups.series.section.hidden = true;
groups.movies.section.hidden = true;
renderLibraryRows([
...seriesShown.map((series) => ({
root_id: series.root_id,
item: seriesCard(series, openSeries),
})),
...moviesShown.map((movie) => ({
root_id: movie.root_id,
item: movieCard(movie, openMovie),
})),
]);
// series and movies share a root section, so the two lists interleave
// before the cards are built — otherwise a satisfied series would sit
// above a movie that still wants a file.
renderLibraryRows(
attentionFirst(
[
...seriesShown.map((series) => ({
root_id: series.root_id,
wants: seriesNeedsAttention(series),
build: () => seriesCard(series, openSeries),
})),
...moviesShown.map((movie) => ({
root_id: movie.root_id,
wants: movieNeedsAttention(movie),
build: () => movieCard(movie, openMovie),
})),
],
(entry) => entry.wants,
).map((entry) => ({ root_id: entry.root_id, item: entry.build() })),
);
} else {
libraryRows.replaceChildren();
libraryRows.hidden = true;
@@ -3820,8 +3830,6 @@ function libraryMain(
if (total === 0) {
setStatus("library is empty — the search box above adds titles");
} else if (seriesShown.length + moviesShown.length === 0) {
setStatus("nothing needs attention");
} else {
setStatus(null);
}
@@ -3862,11 +3870,6 @@ function libraryMain(
sequence += 1;
}
toggle.addEventListener("click", () => {
showAll = !showAll;
render();
});
function repaintDownloads() {
if (!view.hidden) {
render();