feat(channels,datatable): unify sync-status into one controlled DataTable filter (R13 S2a)

The top sync chips and the Sync column's own select filter were two independent
states with different vocab (all/needs_full/fully_synced vs fully/needsFull) and
subtly different semantics. DataTable gains a controlled filters/onFiltersChange
pair (symmetric to the existing controlled sort); ChannelsProvider now owns the
filter map via a new useFilterState hook, so the top chips, the Sync column
funnel, the card layout, and the header "full history" deep-link all drive ONE
filters.sync. The card layout reuses the exported filterRows predicate instead of
its own status pre-filter. Vocab unified on the column's fully = recent_synced &&
backfill_done, which now matches the Sync cell's own "fully synced" badge.
This commit is contained in:
2026-08-14 02:00:17 +02:00
parent 665bc6f7b5
commit 837fdcd408
6 changed files with 181 additions and 61 deletions
+25 -28
View File
@@ -12,33 +12,32 @@ import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
import { type SortState } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
import type { FilterMap } from "./tableHeader";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import type { ChannelStatusFilter, ChannelsView } from "./Channels";
import type { ChannelsView } from "./Channels";
// Owns the Channels module's shared local state — the rail-less manager's name filter, status chip,
// active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). status / view are
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
const STATUS_FILTERS: readonly ChannelStatusFilter[] = ["needs_full", "fully_synced", "all"];
function clampStatus(v: string | null): ChannelStatusFilter {
return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all";
}
function clampView(v: string | null): ChannelsView {
return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
}
type ChannelsState = {
search: string;
statusFilter: ChannelStatusFilter;
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
// column funnel, and the card layout share ONE filter state (the sync chip lives at `filters.sync`).
filters: FilterMap;
view: ChannelsView;
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
sort: SortState;
@@ -53,7 +52,7 @@ type ChannelsState = {
interface ChannelsActions {
setSearch: (q: string) => void;
setStatusFilter: (f: ChannelStatusFilter) => void;
setFilters: (f: FilterMap) => void;
setView: (v: ChannelsView) => void;
setLayout: (l: ChannelLayout) => void;
setSort: (s: SortState) => void;
@@ -66,7 +65,7 @@ interface ChannelsActions {
const StateContext = createContext<ChannelsState>({
search: "",
statusFilter: "all",
filters: {},
view: "subscribed",
sort: null,
filtersResetToken: 0,
@@ -79,7 +78,7 @@ const StateContext = createContext<ChannelsState>({
const LayoutContext = createContext<ChannelLayout>("table");
const ActionsContext = createContext<ChannelsActions>({
setSearch: () => {},
setStatusFilter: () => {},
setFilters: () => {},
setView: () => {},
setLayout: () => {},
setSort: () => {},
@@ -104,9 +103,9 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
const { setPage } = useNavigationActions();
const [search, setSearchState] = useState("");
const [statusFilter, setStatusFilterState] = useState<ChannelStatusFilter>(() =>
clampStatus(getAccountRaw(LS.channelFilter)),
);
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
const [filters, setFilters] = useFilterState(LS.channelsFilterMap);
const [view, setViewState] = useState<ChannelsView>(() =>
clampView(getAccountRaw(LS.channelsView)),
);
@@ -127,10 +126,6 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
viewRef.current = view;
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setStatusFilter = useCallback((f: ChannelStatusFilter) => {
setStatusFilterState(f);
setAccountRaw(LS.channelFilter, f);
}, []);
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
// of jumping straight out to the previous module.
@@ -163,11 +158,13 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
[setView, setPage],
);
const goToFullHistory = useCallback(() => {
setStatusFilter("needs_full");
setView("subscribed"); // the status filter applies to the subscriptions tab
setFiltersResetToken((n) => n + 1); // drop any stale column filter hiding the rows
// One controlled map replaces the old status chip: sync=needsFull, and dropping every other
// column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
setFilters({ sync: "needsFull" });
setView("subscribed"); // the sync filter applies to the subscriptions tab
setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips
setPage("channels");
}, [setStatusFilter, setView, setPage]);
}, [setFilters, setView, setPage]);
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
useEffect(() => {
@@ -196,20 +193,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
}, []);
const state = useMemo<ChannelsState>(
() => ({ search, statusFilter, view, sort, filtersResetToken, focusName, focusToken }),
[search, statusFilter, view, sort, filtersResetToken, focusName, focusToken],
() => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
[search, filters, view, sort, filtersResetToken, focusName, focusToken],
);
const actions = useMemo<ChannelsActions>(
() => ({
setSearch,
setStatusFilter,
setFilters,
setView,
setLayout,
setSort,
focusChannel,
goToFullHistory,
}),
[setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory],
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
);
return (