Files
siftlode/frontend/src/lib/feedSort.ts
T
peter 0b4b268678 test(feed): pin the sort options' default directions as literals
The feedSortOptions test compared each option's default direction against SORT_DEFAULT_DIR — the
implementation's own table, so a wrong entry passed — and that import was the export's only user
outside its module, which kept knip from seeing it had become dead after F3. The test now states the
directions, the table is private again.
2026-09-11 03:19:17 +02:00

116 lines
4.8 KiB
TypeScript

// The feed's sort vocabulary. `FeedFilters.sort` is a single wire string ("newest", "views_asc",
// …); the UI thinks in a key + direction. Kept here rather than inside Feed so the active-filter
// chips can label a sort without re-deriving the mapping.
import type { Video } from "./api";
import type { SortOption } from "./columnSort";
export type SortKey =
| "date"
| "popular"
| "duration"
| "title"
| "subscribers"
| "priority"
| "shuffle"
| "relevance";
export const SORT_KEYS: SortKey[] = [
"date",
"popular",
"duration",
"title",
"subscribers",
"priority",
"shuffle",
];
/** Sorts with no meaningful asc/desc flip. */
const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
date: { desc: "newest", asc: "oldest" },
popular: { desc: "views", asc: "views_asc" },
duration: { desc: "duration_desc", asc: "duration_asc" },
title: { asc: "title", desc: "title_desc" },
subscribers: { desc: "subscribers", asc: "subscribers_asc" },
priority: { desc: "priority", asc: "priority_asc" },
};
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle" | "relevance">, "asc" | "desc"> = {
date: "desc",
popular: "desc",
duration: "desc",
title: "asc",
subscribers: "desc",
priority: "desc",
};
export function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
if (s === "shuffle" || s === "relevance") return { key: s, dir: "desc" };
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
}
return { key: "date", dir: "desc" };
}
export function buildSort(key: SortKey, dir: "asc" | "desc"): string {
if (key === "shuffle" || key === "relevance") return key;
return SORT_MAP[key][dir];
}
/** Feed sort keys as options for the shared SortControl. ONE mapping for the library feed and the
* live search results, which share the vocabulary: two copies could hand the same key a different
* direction default, or lose its directionless flag, depending on which mode you were in. */
export function feedSortOptions(keys: SortKey[], label: (k: SortKey) => string): SortOption[] {
return keys.map((k) => ({
key: k,
label: label(k),
directionless: DIRECTIONLESS.includes(k),
defaultDir: DIRECTIONLESS.includes(k)
? undefined
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
}));
}
// --- Live YouTube search results ----------------------------------------------------------------
//
// The results view had NO sort control at all — the order was the provider's and that was that. It is
// sorted HERE, on the page already loaded, rather than by re-asking YouTube with an `order=` param,
// for three reasons: the owner's case is "even one page of results", i.e. reorder what is on screen;
// a re-query would REPLACE the results, which is not what "sort these" means; and the zero-quota
// scrape source has no order parameter at all, so a server-side control would silently do nothing (or
// quietly cost 100 quota units) depending on which source happened to serve the page.
//
// Only the keys a search result actually carries are offered. `subscribers` and `priority` are
// properties of a SUBSCRIPTION, which a live search result is not, and `shuffle` on a ranked result
// list is noise — an option that cannot mean anything here is worse than no option.
export const YT_SORT_KEYS: SortKey[] = ["relevance", "date", "popular", "duration", "title"];
const YT_SORT_VALUE: Partial<Record<SortKey, (v: Video) => number | string | null | undefined>> = {
date: (v) => (v.published_at ? Date.parse(v.published_at) : null),
popular: (v) => v.view_count,
duration: (v) => v.duration_seconds,
title: (v) => v.title,
};
/** Order a loaded page of live-search results. `relevance` is the provider's own ranking, i.e. the
* order they arrived in — so it is the identity, not a sort.
*
* Results missing the value sink to the END in both directions (a video whose duration YouTube did
* not report is not "the shortest"), and ties keep the provider's ranking, in both directions —
* hence a signed comparator rather than sorting and reversing, which would flip the ties too. */
export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc"): Video[] {
const value = YT_SORT_VALUE[key];
if (!value) return items; // relevance, and anything a stale pref names that we no longer offer
const sign = dir === "asc" ? 1 : -1;
const have = items.filter((v) => value(v) != null);
const missing = items.filter((v) => value(v) == null);
have.sort((a, b) => {
const x = value(a)!;
const y = value(b)!;
return sign * (typeof x === "string" ? x.localeCompare(String(y)) : x - Number(y));
});
return [...have, ...missing];
}