Files
siftlode/frontend/src/lib/columnSort.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

119 lines
5.4 KiB
TypeScript

// Shared column-sort primitives, lifted out of DataTable so a NON-table renderer (the channel
// manager's card layout) can sort the same rows the same way — one comparator, one source of truth.
// Kept structural (no `Column` import) so it has zero coupling to DataTable.
import { compareText } from "./collate";
export type SortState = { key: string; dir: "asc" | "desc" } | null;
interface Sortable<T> {
key: string;
sortValue?: (row: T) => string | number;
}
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff, or text in the UI
* language's alphabet — `lang`). Returns `rows` unchanged when there's no sort or the column has no
* `sortValue`. */
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState, lang: string): T[] {
if (!sort) return rows;
const get = columns.find((c) => c.key === sort.key)?.sortValue;
if (!get) return rows;
return [...rows].sort((a, b) => {
const av = get(a);
const bv = get(b);
const cmp =
typeof av === "number" && typeof bv === "number"
? av - bv
: compareText(String(av), String(bv), lang);
return sort.dir === "asc" ? cmp : -cmp;
});
}
/** Coerce a stored value to a SortState. Anything malformed (corrupt storage, a shape from an older
* build) becomes "no sort" rather than flowing a junk `dir` into the comparator. */
export function parseSortState(raw: unknown): SortState {
if (!raw || typeof raw !== "object") return null;
const { key, dir } = raw as { key?: unknown; dir?: unknown };
return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null;
}
/** Encode a SortState to the compact `key:dir` wire form the shareable-URL modules (plex/channels)
* put in the address bar. Sort keys are simple words (no colon), so the plain join round-trips. Returns
* null for no-sort. A module with a default sort short-circuits that to null at its own call site. */
export function encodeSortParam(s: SortState): string | null {
return s ? `${s.key}:${s.dir}` : null;
}
/** Inverse of `encodeSortParam`: parse `key:dir` (or a bare key → asc) from the URL back into a
* SortState, healing anything malformed to null. One codec for both URL-state modules — no drift. */
export function decodeSortParam(raw: string): SortState {
const i = raw.indexOf(":");
const key = i === -1 ? raw : raw.slice(0, i);
const dir = i === -1 ? "asc" : raw.slice(i + 1);
return parseSortState({ key, dir });
}
/** The header-click cycle: unsorted → ascending → descending → unsorted. */
export function cycleSort(current: SortState, key: string): SortState {
if (!current || current.key !== key) return { key, dir: "asc" };
if (current.dir === "asc") return { key, dir: "desc" };
return null;
}
// --- the source-agnostic sort vocabulary -------------------------------------------------
// A `SortOption[]` is the one shape every content surface (YT feed, Plex library, channel
// manager, discovery) hands to the shared `SortControl`. A table derives it from its columns;
// the feed and Plex declare it explicitly. Direction lives in `SortState`, never encoded into
// the key — the feed's wire string (`feedSort.ts`) is a codec at the server boundary only.
export interface SortOption {
key: string;
label: string;
/** No meaningful asc/desc flip (the feed's `shuffle`/`relevance`) — hides the direction toggle. */
directionless?: boolean;
/** The natural first direction when this option is freshly picked (defaults to `"asc"`). */
defaultDir?: "asc" | "desc";
}
/** Decide the next `SortState` when the user PICKS `key` from a dropdown/chip (NOT the header-click
* cycle — that's `cycleSort`). Re-picking the same key keeps its direction; a new key starts at its
* `defaultDir` (asc fallback); a directionless option carries a stable dir; an empty key clears sort. */
export function pickSort(options: SortOption[], prev: SortState, key: string): SortState {
if (!key) return null;
const opt = options.find((o) => o.key === key);
if (!opt) return null;
if (opt.directionless) return { key, dir: "desc" };
if (prev?.key === key) return prev;
return { key, dir: opt.defaultDir ?? "asc" };
}
/** Collapse a sort that names an option no longer offered (a renamed column, or Plex's movie/show
* scope swap dropping a key) to `fallback` — otherwise a stale key claims a sort nothing applies. */
export function healSort(
sort: SortState,
options: SortOption[],
fallback: SortState = null,
): SortState {
return sort && options.some((o) => o.key === sort.key) ? sort : fallback;
}
/** Hydrate a persisted raw string into a `SortState`. Accepts the JSON `{key,dir}` shape and tolerates
* a legacy bare-key string (older builds stored just the key). When `options` is empty, the value is
* parsed but NOT healed — for a table/provider that owns no vocabulary itself and heals for display
* elsewhere (e.g. `ColumnSortControl`); a non-empty `options` heals an absent key to `fallback`. */
export function hydrateSort(
raw: string | null,
options: SortOption[],
fallback: SortState = null,
): SortState {
if (!raw) return fallback;
const heal = (s: SortState): SortState =>
options.length === 0 ? (s ?? fallback) : healSort(s, options, fallback);
try {
return heal(parseSortState(JSON.parse(raw)));
} catch {
const opt = options.find((o) => o.key === raw);
return opt ? { key: raw, dir: opt.defaultDir ?? "asc" } : fallback;
}
}