From 45bd588af7f958b7b86c49b639d9c12a660432dc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 13 Aug 2026 23:41:12 +0200 Subject: [PATCH] refactor(channels): share useSortState across the manager + discovery (R13 S1) ChannelsProvider and ChannelDiscovery each hand-rolled the same sort plumbing (useState + 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. --- frontend/src/components/ChannelDiscovery.tsx | 15 +++++---------- frontend/src/components/ChannelsProvider.tsx | 16 ++++++---------- frontend/src/lib/columnSort.test.ts | 10 ++++++++++ frontend/src/lib/columnSort.ts | 10 +++++++--- frontend/src/lib/plexSort.ts | 2 +- frontend/src/lib/useSortState.ts | 2 +- 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 6ea311cf..4334425c 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -1,12 +1,13 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { UserPlus } from "lucide-react"; import { api, type DiscoveredChannel } from "../lib/api"; -import { accountKey, LS, readAccount, writeAccount } from "../lib/storage"; +import { accountKey, LS } from "../lib/storage"; import { formatCountOrDash } from "../lib/format"; -import { parseSortState, sortRows, type SortState } from "../lib/columnSort"; +import { sortRows } from "../lib/columnSort"; +import { useSortState } from "../lib/useSortState"; import { useCardPager } from "../lib/useCardPager"; import { subsColumn } from "./channelColumns"; import { notify } from "../lib/notifications"; @@ -44,13 +45,7 @@ export default function ChannelDiscovery({ // The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is // this tab's own (its columns differ), lifted out of the table so the card layout shares it. const layout = useChannelLayout(); - const [sort, setSortState] = useState(() => - parseSortState(readAccount(LS.channelDiscoverySort, null)), - ); - const setSort = (s: SortState) => { - setSortState(s); - writeAccount(LS.channelDiscoverySort, s); - }; + const [sort, setSort] = useSortState(LS.channelDiscoverySort); const query = useQuery({ queryKey: qk.discoveredChannels(), diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 6b6680ce..ca42088e 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -8,9 +8,10 @@ import { useState, type ReactNode, } from "react"; -import { getAccountRaw, LS, readAccount, setAccountRaw, writeAccount } from "../lib/storage"; +import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout"; -import { parseSortState, type SortState } from "../lib/columnSort"; +import { type SortState } from "../lib/columnSort"; +import { useSortState } from "../lib/useSortState"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import type { ChannelStatusFilter, ChannelsView } from "./Channels"; @@ -117,9 +118,9 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { const [layout, setLayoutState] = useState(() => parseChannelLayout(getAccountRaw(LS.channelLayout)), ); - const [sort, setSortState] = useState(() => - parseSortState(readAccount(LS.channelSort, null)), - ); + // Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals + // it against the live columns for display, so the provider stores it un-healed (no options passed). + const [sort, setSort] = useSortState(LS.channelSort); const [filtersResetToken, setFiltersResetToken] = useState(0); const [focusName, setFocusName] = useState(null); const [focusToken, setFocusToken] = useState(0); @@ -157,11 +158,6 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { setLayoutState(l); setAccountRaw(LS.channelLayout, l); }, []); - const setSort = useCallback((s: SortState) => { - setSortState(s); - writeAccount(LS.channelSort, s); - }, []); - const focusChannel = useCallback( (name: string) => { setFocusName(name); diff --git a/frontend/src/lib/columnSort.test.ts b/frontend/src/lib/columnSort.test.ts index 4407896a..6263493c 100644 --- a/frontend/src/lib/columnSort.test.ts +++ b/frontend/src/lib/columnSort.test.ts @@ -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(); + }); }); diff --git a/frontend/src/lib/columnSort.ts b/frontend/src/lib/columnSort.ts index 223add6c..e9ae944f 100644 --- a/frontend/src/lib/columnSort.ts +++ b/frontend/src/lib/columnSort.ts @@ -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; diff --git a/frontend/src/lib/plexSort.ts b/frontend/src/lib/plexSort.ts index d30d78ea..7b7df241 100644 --- a/frontend/src/lib/plexSort.ts +++ b/frontend/src/lib/plexSort.ts @@ -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)]; } diff --git a/frontend/src/lib/useSortState.ts b/frontend/src/lib/useSortState.ts index e459d9ec..332b26f3 100644 --- a/frontend/src/lib/useSortState.ts +++ b/frontend/src/lib/useSortState.ts @@ -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(() =>