fix(e8): deep-review hardening + cut 0.73.0
Fixes from several multi-agent deep-review passes over the E8 feed-filtering & channel-manager epic (S1-S4): - feed_years buckets by UTC year to match the UTC-pinned year-filter bounds - tag suggestions: examples ∩ subscribed (drop tagged-then-unsubscribed channels from the profile denominator); exclude hidden channels as suggestion targets - channel manager: restore ghost chips + surface an error toast on a failed tag accept - year filter: debounce the search term so the per-year facet doesn't refetch per keystroke - channel tags: aria-label the "+N" overflow pills - extract useTagSuggestions hook; add backend + hook tests (profile boundaries, cap, language exclusion, unsubscribed/hidden handling, UTC bucketing, bulk-accept) Bump VERSION to 0.73.0 + release notes.
This commit is contained in:
@@ -101,6 +101,7 @@ function SuggestedChipsLane({
|
||||
onAccept: (tagId: number) => void;
|
||||
onDismiss: (tagId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
@@ -163,6 +164,7 @@ function SuggestedChipsLane({
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${GHOST_PILL} hover:text-accent transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
@@ -424,6 +426,7 @@ export function ChannelTagsControl({
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${pillClass} hover:text-fg transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel, type TagSuggestions } from "../lib/api";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import type { SuggestedTag } from "./ChannelTags";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
@@ -17,6 +16,7 @@ import Tabs, { type TabDef } from "./Tabs";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
@@ -56,11 +56,6 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
cards: LayoutGrid,
|
||||
};
|
||||
|
||||
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
|
||||
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
|
||||
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
|
||||
const HIGH_CONFIDENCE = 0.9;
|
||||
|
||||
export default function Channels() {
|
||||
const me = useMe();
|
||||
const canWrite = me.can_write;
|
||||
@@ -105,10 +100,6 @@ export default function Channels() {
|
||||
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
|
||||
const suggestionsQuery = useQuery({
|
||||
queryKey: qk.tagSuggestions(),
|
||||
queryFn: api.tagSuggestions,
|
||||
});
|
||||
const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
||||
const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
@@ -183,56 +174,29 @@ export default function Channels() {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(id) });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
},
|
||||
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
|
||||
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
|
||||
// failure the tag never actually attached — so the server still suggests it and the ghost chips
|
||||
// must come back rather than leaving an empty cell (honest states). Surface the write failure too.
|
||||
onError: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
notify({ level: "error", message: t("channels.tags.updateFailed") });
|
||||
},
|
||||
});
|
||||
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
|
||||
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload).
|
||||
const tagById = useMemo(
|
||||
() => new Map((tagsQuery.data ?? []).map((tg) => [tg.id, tg])),
|
||||
[tagsQuery.data],
|
||||
);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
|
||||
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
|
||||
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
|
||||
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
|
||||
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
|
||||
const suggestionsFor = (cid: string): SuggestedTag[] =>
|
||||
(suggestions[cid] ?? []).flatMap((s) => {
|
||||
const tag = tagById.get(s.tag_id);
|
||||
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
|
||||
});
|
||||
const dismissSuggestion = (cid: string, tagId: number) =>
|
||||
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
|
||||
const acceptSuggestion = (cid: string, tagId: number) => {
|
||||
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
|
||||
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
|
||||
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
|
||||
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; its onSuccess
|
||||
// re-invalidates suggestions + the channel detail.
|
||||
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
|
||||
if (!old?.[cid]) return old;
|
||||
const next = { ...old };
|
||||
delete next[cid];
|
||||
return next;
|
||||
});
|
||||
toggleTag(cid, tagId, false);
|
||||
};
|
||||
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
|
||||
// the bulk action accepts.
|
||||
const visibleSuggestions = Object.keys(suggestions).flatMap((cid) =>
|
||||
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
|
||||
);
|
||||
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
|
||||
const acceptAllHighConfidence = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.tags.acceptAllTitle"),
|
||||
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
|
||||
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
|
||||
});
|
||||
if (!ok) return;
|
||||
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
|
||||
};
|
||||
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The
|
||||
// /tags/suggestions query, the session dismiss set, and the optimistic accept/dismiss/bulk logic all
|
||||
// live in useTagSuggestions so they can be unit-tested apart from this component.
|
||||
const {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
} = useTagSuggestions({ tags: tagsQuery.data ?? [], toggleTag });
|
||||
|
||||
const syncSubs = useMutation({
|
||||
mutationFn: () => api.syncSubscriptions(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { api, type FeedFilters } from "../lib/api";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { filterChipClass } from "./FilterChipGroup";
|
||||
import CountBadge from "./CountBadge";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
|
||||
// The feed rail's "Year" control (item 8 / E8·S2): quick chips for the most recent years plus a
|
||||
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
|
||||
@@ -32,8 +33,13 @@ export default function YearFilter({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sortByCount, setSortByCount] = useState(false);
|
||||
|
||||
// Debounce the search term (like the sibling summary facet in Sidebar) so typing doesn't refire the
|
||||
// per-year count query on every keystroke — each distinct q is a fresh cache key, so without this
|
||||
// an N-char query would issue N /feed/years requests. The year/date narrowings are dropped: this
|
||||
// list is a live drill-down that must not change when a year (or upload-date) is picked.
|
||||
const facetFilters: FeedFilters = {
|
||||
...filters,
|
||||
q: useDebounced(filters.q, 300),
|
||||
year: undefined,
|
||||
maxAgeDays: undefined,
|
||||
dateFrom: undefined,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Container-logic contract of the tag-suggestion hook (E8·S4), the wiring the presentational
|
||||
// ChannelTagsControl test can't reach: suggestionsFor resolves tag ids to Tags and DROPS a suggestion
|
||||
// whose tag was deleted since the last compute; accept optimistically removes the WHOLE channel from
|
||||
// the /tags/suggestions cache and toggles the tag on; dismiss is session-only and scoped to one
|
||||
// (channel, tag); visible/high-confidence derive correctly. Runs against a real QueryClient so the
|
||||
// optimistic cache write is the actual code path (not a re-implementation).
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
// The confirm dialog's answer is controllable per test (the bulk-accept path gates on it).
|
||||
const confirmState = vi.hoisted(() => ({ result: true }));
|
||||
vi.mock("./ConfirmProvider", () => ({
|
||||
useConfirm: () => () => Promise.resolve(confirmState.result),
|
||||
}));
|
||||
|
||||
import { api, type Tag } from "../lib/api";
|
||||
import type { TagSuggestions } from "../lib/api/tags";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const tags = [docu, history];
|
||||
|
||||
// UC_a: two suggestions (one high-confidence). UC_b: one. UC_ghost: a suggestion for a tag id that no
|
||||
// longer exists in the catalog (deleted since compute) → must be filtered out everywhere.
|
||||
const seed = (): TagSuggestions => ({
|
||||
UC_a: [
|
||||
{ tag_id: docu.id, confidence: 0.95 },
|
||||
{ tag_id: history.id, confidence: 0.6 },
|
||||
],
|
||||
UC_b: [{ tag_id: history.id, confidence: 0.7 }],
|
||||
UC_ghost: [{ tag_id: 999, confidence: 0.99 }],
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const qc = new QueryClient({
|
||||
// Freeze the seeded cache so no refetch clobbers the optimistic writes under test.
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnMount: false } },
|
||||
});
|
||||
qc.setQueryData(qk.tagSuggestions(), seed());
|
||||
vi.spyOn(api, "tagSuggestions").mockResolvedValue(seed());
|
||||
const toggleTag = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useTagSuggestions({ tags, toggleTag }), { wrapper });
|
||||
return { qc, toggleTag, result };
|
||||
}
|
||||
|
||||
describe("useTagSuggestions", () => {
|
||||
it("resolves tag ids to Tags and drops suggestions whose tag was deleted", () => {
|
||||
const { result } = setup();
|
||||
const forA = result.current.suggestionsFor("UC_a");
|
||||
expect(forA.map((s) => s.tag.id)).toEqual([docu.id, history.id]);
|
||||
// The suggestion for the missing tag id 999 is dropped, not rendered as a broken chip.
|
||||
expect(result.current.suggestionsFor("UC_ghost")).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives visible and high-confidence suggestions, excluding the deleted-tag one", () => {
|
||||
const { result } = setup();
|
||||
// UC_a(docu 0.95) + UC_a(history 0.6) + UC_b(history 0.7) = 3; UC_ghost excluded.
|
||||
expect(result.current.visibleSuggestions).toHaveLength(3);
|
||||
// Only the ≥0.9 one (UC_a/docu) is high-confidence.
|
||||
expect(result.current.highConfidence).toEqual([
|
||||
{ cid: "UC_a", tagId: docu.id, confidence: 0.95 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accept optimistically removes the whole channel from the cache and toggles the tag on", () => {
|
||||
const { qc, toggleTag, result } = setup();
|
||||
act(() => result.current.acceptSuggestion("UC_a", docu.id));
|
||||
// The real tag is attached (hasTag=false → attach).
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
// The whole channel is dropped from the suggestions cache (attached lane XOR ghost lane), so its
|
||||
// OTHER ghost chip (history) goes too — not just the accepted one.
|
||||
const cache = qc.getQueryData<TagSuggestions>(qk.tagSuggestions());
|
||||
expect(cache?.UC_a).toBeUndefined();
|
||||
expect(cache?.UC_b).toBeDefined(); // other channels untouched
|
||||
});
|
||||
|
||||
it("dismiss hides only that one (channel, tag) for the session", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.dismissSuggestion("UC_a", history.id));
|
||||
// UC_a loses only the history suggestion; docu stays.
|
||||
expect(result.current.suggestionsFor("UC_a").map((s) => s.tag.id)).toEqual([docu.id]);
|
||||
// The same tag on a DIFFERENT channel is not dismissed (dismiss is scoped to the pair).
|
||||
expect(result.current.suggestionsFor("UC_b").map((s) => s.tag.id)).toEqual([history.id]);
|
||||
});
|
||||
|
||||
it("bulk-accept applies ONLY the high-confidence suggestions after a confirm", async () => {
|
||||
confirmState.result = true;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
// Only UC_a/docu (0.95 ≥ 0.9) is accepted — not history (0.6) or UC_b/history (0.7).
|
||||
expect(toggleTag).toHaveBeenCalledTimes(1);
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
});
|
||||
|
||||
it("bulk-accept does nothing when the confirm is cancelled", async () => {
|
||||
confirmState.result = false;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
expect(toggleTag).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type Tag, type TagSuggestions } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import type { SuggestedTag } from "./ChannelTags";
|
||||
|
||||
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
|
||||
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
|
||||
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
|
||||
const HIGH_CONFIDENCE = 0.9;
|
||||
|
||||
type VisibleSuggestion = { cid: string; tagId: number; confidence: number };
|
||||
|
||||
/** Suggested-tag state for the Channel Manager (E8·S4): the /tags/suggestions query, a session-only
|
||||
* dismiss set, and the accept / dismiss / bulk-accept actions. Suggestions are learned from the
|
||||
* user's own manual-tag pattern and shown as ghost chips on UNTAGGED channels. Extracted as a hook
|
||||
* (like useChannelTagActions) so the optimistic-cache and dismiss logic is unit-testable apart from
|
||||
* the large Channels component. */
|
||||
export function useTagSuggestions({
|
||||
tags,
|
||||
toggleTag,
|
||||
}: {
|
||||
/** The full tag catalog, to resolve a suggestion's tag_id → Tag (and drop tags deleted since compute). */
|
||||
tags: Tag[];
|
||||
/** The shared attach/detach toggle. Its onError must also refetch suggestions (see Channels) so a
|
||||
* failed accept restores the ghost chips this hook optimistically removed. */
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const suggestionsQuery = useQuery({ queryKey: qk.tagSuggestions(), queryFn: api.tagSuggestions });
|
||||
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
|
||||
const tagById = useMemo(() => new Map(tags.map((tg) => [tg.id, tg])), [tags]);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
|
||||
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
|
||||
|
||||
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
|
||||
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
|
||||
const suggestionsFor = (cid: string): SuggestedTag[] =>
|
||||
(suggestions[cid] ?? []).flatMap((s) => {
|
||||
const tag = tagById.get(s.tag_id);
|
||||
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
|
||||
});
|
||||
|
||||
const dismissSuggestion = (cid: string, tagId: number) =>
|
||||
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
|
||||
|
||||
const acceptSuggestion = (cid: string, tagId: number) => {
|
||||
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
|
||||
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
|
||||
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
|
||||
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; on success
|
||||
// its onSuccess re-invalidates suggestions, and on failure its onError refetches them — so a
|
||||
// failed accept restores these ghost chips rather than leaving an empty cell (see Channels' onError).
|
||||
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
|
||||
if (!old?.[cid]) return old;
|
||||
const next = { ...old };
|
||||
delete next[cid];
|
||||
return next;
|
||||
});
|
||||
toggleTag(cid, tagId, false);
|
||||
};
|
||||
|
||||
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
|
||||
// the bulk action accepts.
|
||||
const visibleSuggestions: VisibleSuggestion[] = Object.keys(suggestions).flatMap((cid) =>
|
||||
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
|
||||
);
|
||||
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
|
||||
const acceptAllHighConfidence = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.tags.acceptAllTitle"),
|
||||
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
|
||||
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
|
||||
});
|
||||
if (!ok) return;
|
||||
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
|
||||
};
|
||||
|
||||
return {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user