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
+4 -1
View File
@@ -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",
@@ -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<string, string>() }));
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" });
});
});
+37
View File
@@ -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<FilterMap>(() => 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 {};
}
}