sort dropdown on every column, auto-process checkbox stays visible during sorting
Build and Push Docker Image / build (push) Successful in 1m10s

- ColumnShell: new sort row below header border with sortOptions/sortValue/onSortChange
- inbox: ↓↑ scan time, ↓↑ name (dropdown moved from button row to sort row)
- review: classification (default), ↓↑ scan time, ↓↑ name
- queue/done: ↓↑ added, ↓↑ name (client-side sort on already-fetched arrays)
- auto-process checkbox stays visible during inbox processing, progress shows below it
- backend: unified GroupSort type replaces InboxSort, review bucket accepts sort param

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 09:36:20 +02:00
parent 789a9f7bfe
commit 7900f450a7
8 changed files with 201 additions and 115 deletions
+23 -25
View File
@@ -633,19 +633,17 @@ type ReviewGroup =
seasons: Array<{ season: number | null; episodes: ReviewItemRow[] }>;
};
export type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc";
export type GroupSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc" | "class";
export interface BuildReviewGroupsOpts {
bucket: "inbox" | "review";
sort?: InboxSort;
sort?: GroupSort;
}
function orderClause(bucket: "inbox" | "review", sort?: InboxSort): string {
if (bucket === "review") {
function orderClause(sort?: GroupSort): string {
if (sort === "class")
return `CASE rp.auto_class WHEN 'auto_heuristic' THEN 0 WHEN 'manual' THEN 1 ELSE 2 END,
COALESCE(mi.series_name, mi.name),
mi.season_number, mi.episode_number`;
}
COALESCE(mi.series_name, mi.name), mi.season_number, mi.episode_number`;
if (sort === "scan_desc") return "mi.last_scanned_at DESC, mi.id DESC";
if (sort === "name_asc") return "COALESCE(mi.series_name, mi.name) ASC, mi.season_number, mi.episode_number";
if (sort === "name_desc") return "COALESCE(mi.series_name, mi.name) DESC, mi.season_number, mi.episode_number";
@@ -657,7 +655,8 @@ export function buildReviewGroups(
opts: BuildReviewGroupsOpts,
): { groups: ReviewGroup[]; totalItems: number } {
const sortedFilter = opts.bucket === "inbox" ? "rp.sorted = 0" : "rp.sorted = 1";
const order = orderClause(opts.bucket, opts.sort);
const defaultSort: GroupSort = opts.bucket === "inbox" ? "scan_asc" : "class";
const order = orderClause(opts.sort ?? defaultSort);
const rows = db
.prepare(`
SELECT rp.*, mi.name, mi.series_name, mi.series_key,
@@ -728,12 +727,21 @@ export function buildReviewGroups(
});
}
// For inbox, preserve the SQL order (scan time or name). For review,
// rank by auto_class so auto-approvable items float to the top.
const effectiveSort = opts.sort ?? (opts.bucket === "inbox" ? "scan_asc" : "class");
let allGroups: ReviewGroup[];
if (opts.bucket === "inbox") {
// Interleave movies and series in the order their first row appeared
// in the SQL result set so scan-time ordering stays intact.
if (effectiveSort === "class") {
// Class sort: rank by auto_class in JS so auto-approvable items float top.
allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => {
const rankA = a.kind === "movie" ? autoClassRank(a.item.auto_class) : a.readyCount > 0 ? 0 : 1;
const rankB = b.kind === "movie" ? autoClassRank(b.item.auto_class) : b.readyCount > 0 ? 0 : 1;
if (rankA !== rankB) return rankA - rankB;
const nameA = a.kind === "movie" ? a.item.name : a.seriesName;
const nameB = b.kind === "movie" ? b.item.name : b.seriesName;
return nameA.localeCompare(nameB);
});
} else {
// Scan-time / name sorts: interleave movies and series in SQL row order.
const groupOrder: ReviewGroup[] = [];
const seen = new Set<string>();
for (const row of rows) {
@@ -755,15 +763,6 @@ export function buildReviewGroups(
}
}
allGroups = groupOrder;
} else {
allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => {
const rankA = a.kind === "movie" ? autoClassRank(a.item.auto_class) : a.readyCount > 0 ? 0 : 1;
const rankB = b.kind === "movie" ? autoClassRank(b.item.auto_class) : b.readyCount > 0 ? 0 : 1;
if (rankA !== rankB) return rankA - rankB;
const nameA = a.kind === "movie" ? a.item.name : a.seriesName;
const nameB = b.kind === "movie" ? b.item.name : b.seriesName;
return nameA.localeCompare(nameB);
});
}
const totalItems =
@@ -784,9 +783,8 @@ app.get("/groups", (c) => {
const bucketParam = c.req.query("bucket") ?? "review";
const bucket = bucketParam === "inbox" ? "inbox" : "review";
const sortParam = c.req.query("sort") as InboxSort | undefined;
const sort = bucket === "inbox" ? (sortParam ?? "scan_asc") : undefined;
const { groups, totalItems } = buildReviewGroups(db, { bucket, sort });
const sortParam = c.req.query("sort") as GroupSort | undefined;
const { groups, totalItems } = buildReviewGroups(db, { bucket, sort: sortParam });
const page = groups.slice(offset, offset + limit);
const flat: EnrichableRow[] = [];