refactor(channels): share useSortState across the manager + discovery (R13 S1)

ChannelsProvider and ChannelDiscovery each hand-rolled the same sort plumbing
(useState<SortState> + parseSortState + a persist key). Both now use the shared
useSortState hook. hydrateSort gains an empty-options rule: with no vocabulary
passed it parses without healing (these owners heal for display via
ColumnSortControl against the live columns), so the un-healed provider store is
preserved. plexSortKeys made module-private (knip).

Closes R13 S1: feed, Plex, and the two channel tables all sort through one
SortControl + one SortState model. Gate green: tsc/eslint(0 err)/prettier/knip
clean, vitest 94.
This commit is contained in:
2026-08-13 23:41:12 +02:00
parent e4bdc5ea6a
commit 45bd588af7
6 changed files with 30 additions and 25 deletions
+10
View File
@@ -169,4 +169,14 @@ describe("hydrateSort", () => {
dir: "desc",
});
});
it("parses without healing when no options are supplied (caller heals for display)", () => {
// A table/provider that owns no vocabulary keeps whatever was stored, even an unknown key.
expect(hydrateSort(JSON.stringify({ key: "anything", dir: "desc" }), [])).toEqual({
key: "anything",
dir: "desc",
});
// A malformed stored value still coerces to the fallback.
expect(hydrateSort(JSON.stringify({ key: 3, dir: "up" }), [], null)).toBeNull();
});
});
+7 -3
View File
@@ -78,16 +78,20 @@ export function healSort(
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. */
/** 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 healSort(parseSortState(JSON.parse(raw)), options, fallback);
return heal(parseSortState(JSON.parse(raw)));
} catch {
const opt = options.find((o) => o.key === raw);
return opt ? { key: raw, dir: opt.defaultDir ?? "asc" } : fallback;
+1 -1
View File
@@ -8,7 +8,7 @@ const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"]
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
/** The sort keys a scope offers — movie libraries add `duration`; shows/both don't. */
export function plexSortKeys(scope: string): string[] {
function plexSortKeys(scope: string): string[] {
return [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)];
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { hydrateSort, type SortOption, type SortState } from "./columnSort";
// key). One home so a new sortable surface (Plex, a future table) gets persistence for free.
export function useSortState(
storageBase: string,
options: SortOption[],
options: SortOption[] = [],
fallback: SortState = null,
): [SortState, (next: SortState) => void] {
const [sort, setSort] = useState<SortState>(() =>