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:
2026-08-26 05:25:47 +02:00
parent 17f8885fb1
commit 7104767ff9
13 changed files with 519 additions and 70 deletions
+3
View File
@@ -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()}
>
+22 -58
View File
@@ -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(),
+6
View File
@@ -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,
};
}
@@ -52,6 +52,7 @@
"create": "Create tag",
"createPlaceholder": "New tag…",
"createFailed": "Could not create the tag.",
"updateFailed": "Could not update the tag.",
"noTag": "No tag",
"moreCount_one": "{{count}} more tag",
"moreCount_other": "{{count}} more tags",
@@ -52,6 +52,7 @@
"create": "Címke létrehozása",
"createPlaceholder": "Új címke…",
"createFailed": "A címke létrehozása nem sikerült.",
"updateFailed": "A címke frissítése nem sikerült.",
"noTag": "Nincs címke",
"moreCount_one": "még {{count}} címke",
"moreCount_other": "még {{count}} címke",
+15
View File
@@ -14,6 +14,21 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.73.0",
date: "2026-08-26",
summary:
"Feed filtering and the Channel Manager catch up with Plex: filter to channels you haven't tagged, filter the feed by year, hover any card for its full details, and let Siftlode suggest your own tags for untagged channels based on how you've tagged similar ones.",
features: [
"New “No tag” filter — narrow the feed and the Channel Manager to channels you haven't personally tagged yet, so what still needs organising is easy to find.",
"Filter the feed by year: quick chips for recent years with per-year video counts, plus a “More…” dialog listing every year, sortable by year or count.",
"Hover (or keyboard-focus) any feed card for an info box with the full title, channel, date, duration, a description snippet, and the channel's tags — without opening the video.",
"The Channel Manager now suggests your own tags for untagged channels, learned from how you've tagged similar ones. Accept a suggestion with one tap, dismiss it, or accept all the confident ones at once.",
],
chores: [
"The whole epic went through several multi-agent deep-review passes — findings fixed and re-reviewed each round until it came back clean.",
],
},
{
version: "0.72.1",
date: "2026-08-24",