Files
siftlode/frontend/src/lib/feedSort.ts
T
peter aac79ffa53 fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.

- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
  the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
  outside lib/format.ts, localeCompare, and a component reading the language
  off the i18n singleton
2026-09-29 22:39:57 +02:00

122 lines
4.9 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";
import { compareText } from "./collate";
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",
lang: string,
): 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" ? compareText(x, String(y), lang) : x - Number(y));
});
return [...have, ...missing];
}