refactor(sort): source-agnostic SortControl + SortOption model (R13 S1 foundation)

Introduce the one sort control every content surface will share:
- columnSort.ts gains the SortOption vocabulary + pure pickSort/healSort/
  hydrateSort helpers (direction lives in SortState, never in the key).
- SortControl.tsx: the generic dropdown + asc/desc toggle, with an optional
  "no sort" choice (tables) and a trailing adornment slot (the feed's reshuffle).
- ColumnSortControl is now a thin wrapper over SortControl (API unchanged, so
  Channels + ChannelDiscovery are untouched); optionsFromColumns moved to
  lib/sortOptions.ts to keep the component file fast-refresh clean.
- useSortState.ts: the per-account persist/hydrate/heal plumbing the channel
  manager and discovery each hand-rolled, ready to replace both.

Pure logic is fully unit-tested (columnSort.test.ts 10 -> 23). Gate green:
tsc/eslint/prettier clean, vitest 93 pass. Feed/Plex/channels migration follows.
This commit is contained in:
2026-08-13 23:24:59 +02:00
parent f3b95ed543
commit 7d9da80795
6 changed files with 260 additions and 44 deletions
+53
View File
@@ -40,3 +40,56 @@ export function cycleSort(current: SortState, key: string): SortState {
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 healed `SortState`. Accepts the JSON `{key,dir}` shape and
* tolerates a legacy bare-key string (older builds stored just the key), falling back otherwise. */
export function hydrateSort(
raw: string | null,
options: SortOption[],
fallback: SortState = null,
): SortState {
if (!raw) return fallback;
try {
return healSort(parseSortState(JSON.parse(raw)), options, fallback);
} catch {
const opt = options.find((o) => o.key === raw);
return opt ? { key: raw, dir: opt.defaultDir ?? "asc" } : fallback;
}
}