feat(feed,channels): "No tag" filter for untagged channels (E8·S1)

Filter to channels you haven't personally tagged in the channel manager
(no user-owned ChannelTag; auto language/topic tags don't count):

- Feed: a "No tag" sentinel in the "Your tags" sidebar combobox (exclusive
  with real tag picks) + a removable "Untagged" chip; new `untagged` bool
  feed param / `notag` URL key, persisted + coerced on every filter path.
- Channel manager: a virtual "No tag" chip in the tag-chip row filters the
  table to untagged channels (client-side, shared NO_TAG_SENTINEL).

Backend `untagged` is a plain channel-level narrowing, so feed_summary keeps
it applied in the base pass — the count/facet lockstep holds with no mirror.

Note: item E (per-tag counts) and item D (Any/All toggle) were already
shipped; S1 delivers only item B.
This commit is contained in:
2026-08-25 01:44:34 +02:00
parent 5d7be687d3
commit 4ee0dab68f
22 changed files with 223 additions and 34 deletions
+4
View File
@@ -71,6 +71,9 @@ export interface FeedFilters {
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
librarySource?: "organic" | "all" | "search" | "plex";
/** "No tag" filter (item B): keep only channels the user has NOT personally tagged in the channel
* manager (no user-owned ChannelTag). Auto/system tags don't count. */
untagged?: boolean;
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
channelIds: string[];
maxAgeDays?: number;
@@ -89,6 +92,7 @@ function filterParams(f: FeedFilters): URLSearchParams {
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
}
if (f.untagged) p.set("untagged", "true");
if (f.q) p.set("q", f.q);
p.set("scope", f.scope);
p.set("show_normal", String(f.includeNormal));
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { coerceTagModes, DEFAULT_TAG_MODES } from "./tagModes";
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES } from "./tagModes";
// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL
// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`.
@@ -40,3 +40,15 @@ describe("coerceTagModes", () => {
expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or");
});
});
// coerceUntagged guards the "No tag" (item B) flag on the same persisted/URL/saved-view paths.
describe("coerceUntagged", () => {
it("is true only for an explicit untagged:true", () => {
expect(coerceUntagged({ untagged: true })).toBe(true);
expect(coerceUntagged(undefined)).toBe(false);
expect(coerceUntagged({})).toBe(false);
expect(coerceUntagged(null)).toBe(false);
expect(coerceUntagged({ untagged: "true" })).toBe(false);
expect(coerceUntagged({ untagged: ["other"] })).toBe(false);
});
});
+10
View File
@@ -10,6 +10,11 @@ export type TagModes = Record<TagCategory, TagMode>;
export const TAG_CATEGORIES: readonly TagCategory[] = ["language", "topic", "other"];
export const DEFAULT_TAG_MODES: TagModes = { language: "or", topic: "or", other: "or" };
/** UI sentinel for the "No tag" (item B) option — the feed's user-tags combobox and the channel
* manager's tag chip row both use it to mean "channels I haven't personally tagged". Not a real tag
* id (those are numeric strings), so it can never collide with one. */
export const NO_TAG_SENTINEL = "__none__";
/** Normalise a persisted/shared filter's tag match into the per-category shape, migrating the legacy
* global `tagMode` (which only ever affected topics) into the topic slot. */
export function coerceTagModes(raw: unknown): TagModes {
@@ -22,3 +27,8 @@ export function coerceTagModes(raw: unknown): TagModes {
};
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
}
/** Normalise a persisted/shared filter's "No tag" (item B) flag to a boolean. */
export function coerceUntagged(raw: unknown): boolean {
return (raw as { untagged?: unknown } | null)?.untagged === true;
}