From 837fdcd408c8bd00ff095d33172a3ea1290f15f1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 14 Aug 2026 02:00:17 +0200 Subject: [PATCH] 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. --- frontend/src/components/Channels.tsx | 59 ++++++++++---------- frontend/src/components/ChannelsProvider.tsx | 53 +++++++++--------- frontend/src/components/DataTable.tsx | 32 +++++++++-- frontend/src/lib/storage.ts | 5 +- frontend/src/lib/useFilterState.dom.test.ts | 56 +++++++++++++++++++ frontend/src/lib/useFilterState.ts | 37 ++++++++++++ 6 files changed, 181 insertions(+), 61 deletions(-) create mode 100644 frontend/src/lib/useFilterState.dom.test.ts create mode 100644 frontend/src/lib/useFilterState.ts diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 6267e15f..82718126 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -21,6 +21,7 @@ import ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout"; import { sortRows } from "../lib/columnSort"; +import { filterRows } from "./tableHeader"; import { useCardPager } from "../lib/useCardPager"; import { PageToolbar } from "./PageShell"; import ChannelDiscovery from "./ChannelDiscovery"; @@ -34,12 +35,14 @@ import { useMe } from "../lib/useMe"; export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked"; -export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced"; - -const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ - { id: "all", labelKey: "channels.filters.all" }, - { id: "needs_full", labelKey: "channels.filters.needsFull" }, - { id: "fully_synced", labelKey: "channels.filters.fullySynced" }, +// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared +// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the +// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as +// off). fullySynced = recent_synced && backfill_done (matches the cell's own "fully synced" badge). +const SYNC_CHIPS: { value: string; labelKey: string }[] = [ + { value: "", labelKey: "channels.filters.all" }, + { value: "needsFull", labelKey: "channels.filters.needsFull" }, + { value: "fully", labelKey: "channels.filters.fullySynced" }, ]; const LAYOUT_ICON: Record = { @@ -58,7 +61,7 @@ export default function Channels() { // callers); this page is their primary editor. const { search, - statusFilter, + filters, view, sort, filtersResetToken, @@ -68,7 +71,7 @@ export default function Channels() { const layout = useChannelLayout(); const { setSearch, - setStatusFilter, + setFilters, setView, setLayout, setSort, @@ -223,20 +226,13 @@ export default function Channels() { setTagFilter([]); }, [filtersResetToken]); - // The Sync-status filter stays a top-level chip set (not a column filter) because the - // header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination - // run over whatever the status chip leaves. Hidden channels have their own tab, so they're - // excluded from the subscribed table (no more dimmed rows) rather than shown via a status chip. + // The sync-status filter is now ONE controlled column filter (`filters.sync`) shared by the top + // chips, the Sync column funnel, and the card layout — no separate pre-filter. Hidden channels have + // their own tab, so they're excluded from the subscribed table (no more dimmed rows). The name + // search + tag chips still filter here; the column filters (sync + priority/tags/text funnels) are + // applied by DataTable in table mode and by filterRows below in card mode, over the same `columns`. const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden); - const channels = (channelsQuery.data ?? []) - .filter((c) => !c.hidden) - .filter((c) => - statusFilter === "needs_full" - ? !c.backfill_done - : statusFilter === "fully_synced" - ? c.backfill_done - : true, - ); + const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden); const q = search.trim().toLowerCase(); const visibleChannels = channels.filter((c) => { if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false; @@ -290,16 +286,17 @@ export default function Channels() { // Status chips sit in the table's controls row (next to paging) — compact and close to // where they act. + const syncFilter = (filters.sync as string) ?? ""; const statusChips = (
- {STATUS_FILTERS.map((f) => ( + {SYNC_CHIPS.map((c) => ( ))}
@@ -409,7 +406,11 @@ export default function Channels() { // Only computed for cards: in table mode DataTable sorts and slices internally, so doing it here // too would copy+sort the whole ~300-row array on every render for a result nothing reads. const isCards = layout === "cards"; - const sortedChannels = isCards ? sortRows(visibleChannels, columns, sort) : visibleChannels; + // Cards have no column headers, so apply the shared column filters (sync + any funnel set in table + // mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort. + const sortedChannels = isCards + ? sortRows(filterRows(visibleChannels, columns, filters), columns, sort) + : visibleChannels; const { paged: pagedCards, pagerProps, @@ -422,7 +423,7 @@ export default function Channels() { // handled by the hook's clamp instead. useEffect(() => { resetPage(); - }, [statusFilter, search, tagFilter, sort, resetPage]); + }, [filters, search, tagFilter, sort, resetPage]); const cardPager = ; const tabs = ( @@ -578,6 +579,8 @@ export default function Channels() { controlsBandClassName="px-4 max-w-384 mx-auto" sort={sort} onSortChange={setSort} + filters={filters} + onFiltersChange={setFilters} emptyText={t("channels.empty")} /> ) : ( diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 833b381e..df04e989 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -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 ). // // 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({ search: "", - statusFilter: "all", + filters: {}, view: "subscribed", sort: null, filtersResetToken: 0, @@ -79,7 +78,7 @@ const StateContext = createContext({ const LayoutContext = createContext("table"); const ActionsContext = createContext({ 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(() => - 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(() => 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( - () => ({ 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( () => ({ setSearch, - setStatusFilter, + setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory, }), - [setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory], + [setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory], ); return ( diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index a1750074..c15334b1 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -53,6 +53,8 @@ export default function DataTable({ resetFiltersToken, sort: sortProp, onSortChange, + filters: filtersProp, + onFiltersChange, }: { rows: T[]; columns: Column[]; @@ -82,6 +84,13 @@ export default function DataTable({ // persistKey-backed sort. sort?: SortState; onSortChange?: (next: SortState) => void; + // CONTROLLED filters (optional): symmetric to controlled sort. When `onFiltersChange` is given the + // caller owns the column-filter map (and its persistence) — so the channel manager's top status + // chips, the Sync column funnel, and the card layout all share ONE filter state — and the table + // reflects `filters` and reports funnel changes instead of keeping its own. Omit both for the + // default internal, persistKey-backed filters. + filters?: FilterMap; + onFiltersChange?: (next: FilterMap) => void; }) { const { t } = useTranslation(); const initial = loadPersist(persistKey); @@ -90,17 +99,31 @@ export default function DataTable({ const controlledSort = onSortChange !== undefined; const [internalSort, setInternalSort] = useState(initial.sort); const sort = controlledSort ? (sortProp ?? null) : internalSort; - const [filters, setFilters] = useState(initial.filters); + // Filters are controlled when the caller passes onFiltersChange (it then owns/persists the map); + // otherwise the table keeps its own, seeded from and saved to persistKey — symmetric to sort. + const controlledFilters = onFiltersChange !== undefined; + const [internalFilters, setInternalFilters] = useState(initial.filters); + const filters = controlledFilters ? (filtersProp ?? {}) : internalFilters; const [page, setPage] = useState(initial.page); const [size, setSize] = useState(initial.size ?? pageSize); + // Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the + // latest resolved map so the externalFilter/reset effects can derive from it without taking + // `filters` as a dep (which would re-run them on every filter change). + const filtersRef = useRef(filters); + filtersRef.current = filters; + const commitFilters = (next: FilterMap) => { + if (controlledFilters) onFiltersChange!(next); + else setInternalFilters(next); + }; + useEffect(() => { if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size })); }, [persistKey, sort, filters, page, size]); useEffect(() => { if (!externalFilter) return; - setFilters((f) => ({ ...f, [externalFilter.key]: externalFilter.value })); + commitFilters({ ...filtersRef.current, [externalFilter.key]: externalFilter.value }); setPage(0); // eslint-disable-next-line react-hooks/exhaustive-deps }, [externalFilter?.key, externalFilter?.value]); @@ -113,8 +136,9 @@ export default function DataTable({ useEffect(() => { if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return; resetRef.current = resetFiltersToken; - setFilters({}); + commitFilters({}); setPage(0); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [resetFiltersToken]); const sorted = sortRows(filterRows(rows, columns, filters), columns, sort); @@ -132,7 +156,7 @@ export default function DataTable({ setPage(0); } function applyFilter(key: string, value: string | string[]) { - setFilters((f) => ({ ...f, [key]: value })); + commitFilters({ ...filters, [key]: value }); setPage(0); } diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 393e7462..b783c93a 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -22,7 +22,10 @@ export const LS = { // until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so // the per-account key can't be read. feedView: "siftlode.feedView", - channelFilter: "siftlode.channelFilter", + // The subscribed table's column filters (a FilterMap), lifted out of the table so the top status + // chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes + // the old single-string `channelFilter` sync chip. + channelsFilterMap: "siftlode.channelsFilterMap", channelsView: "siftlode.channelsView", // The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account. channelLayout: "siftlode.channelLayout", diff --git a/frontend/src/lib/useFilterState.dom.test.ts b/frontend/src/lib/useFilterState.dom.test.ts new file mode 100644 index 00000000..dc444dfd --- /dev/null +++ b/frontend/src/lib/useFilterState.dom.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +// +// The persistence/hydration wiring of useFilterState. storage is mocked with an in-memory map so the +// hook can be exercised without a logged-in account (getAccountRaw/setAccountRaw are account-keyed in +// the real module) — mirrors useSortState.dom.test.ts. +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +const { store } = vi.hoisted(() => ({ store: new Map() })); +vi.mock("./storage", () => ({ + getAccountRaw: (k: string) => store.get(k) ?? null, + setAccountRaw: (k: string, v: string) => { + store.set(k, v); + }, +})); + +import { useFilterState } from "./useFilterState"; + +describe("useFilterState", () => { + beforeEach(() => store.clear()); + + it("hydrates to an empty map when storage is empty", () => { + const { result } = renderHook(() => useFilterState("k")); + expect(result.current[0]).toEqual({}); + }); + + it("hydrates a stored map on mount, keeping string and string[] entries", () => { + store.set("k", JSON.stringify({ sync: "needsFull", tags: ["1", "2"] })); + const { result } = renderHook(() => useFilterState("k")); + expect(result.current[0]).toEqual({ sync: "needsFull", tags: ["1", "2"] }); + }); + + it("persists on set, and a fresh mount reads it back (write → reload → hydrate)", () => { + const first = renderHook(() => useFilterState("k")); + act(() => first.result.current[1]({ sync: "fully" })); + expect(first.result.current[0]).toEqual({ sync: "fully" }); + expect(store.get("k")).toBe(JSON.stringify({ sync: "fully" })); + const second = renderHook(() => useFilterState("k")); + expect(second.result.current[0]).toEqual({ sync: "fully" }); + }); + + it("heals a non-object or malformed stored value to an empty map", () => { + store.set("k", "not json"); + expect(renderHook(() => useFilterState("k")).result.current[0]).toEqual({}); + store.set("k2", JSON.stringify(["a", "b"])); + expect(renderHook(() => useFilterState("k2")).result.current[0]).toEqual({}); + store.set("k3", JSON.stringify(null)); + expect(renderHook(() => useFilterState("k3")).result.current[0]).toEqual({}); + }); + + it("drops entries whose value is neither a string nor a string[] of strings", () => { + store.set("k", JSON.stringify({ sync: "needsFull", n: 3, mixed: ["a", 2], obj: {} })); + const { result } = renderHook(() => useFilterState("k")); + expect(result.current[0]).toEqual({ sync: "needsFull" }); + }); +}); diff --git a/frontend/src/lib/useFilterState.ts b/frontend/src/lib/useFilterState.ts new file mode 100644 index 00000000..facc3be4 --- /dev/null +++ b/frontend/src/lib/useFilterState.ts @@ -0,0 +1,37 @@ +import { useCallback, useState } from "react"; +import { getAccountRaw, setAccountRaw } from "./storage"; +import type { FilterMap } from "../components/tableHeader"; + +// The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the +// caller (so a second view of the same rows — the channel manager's card grid, the top status chips +// — shares one filter state). The table analogue of useSortState: one home so a controlled-filter +// surface gets persistence for free, seeded from and saved to `storageBase`. +export function useFilterState(storageBase: string): [FilterMap, (next: FilterMap) => void] { + const [filters, setFilters] = useState(() => hydrate(getAccountRaw(storageBase))); + const set = useCallback( + (next: FilterMap) => { + setFilters(next); + setAccountRaw(storageBase, JSON.stringify(next)); + }, + [storageBase], + ); + return [filters, set]; +} + +// A stored map is only trusted when it parses to a plain object of string / string[] entries; any +// other shape (a legacy value, hand-edited storage) heals to empty rather than throwing downstream. +function hydrate(raw: string | null): FilterMap { + if (!raw) return {}; + try { + const v = JSON.parse(raw); + if (!v || typeof v !== "object" || Array.isArray(v)) return {}; + const out: FilterMap = {}; + for (const [key, val] of Object.entries(v)) { + if (typeof val === "string") out[key] = val; + else if (Array.isArray(val) && val.every((x) => typeof x === "string")) out[key] = val; + } + return out; + } catch { + return {}; + } +}