feat(feed): per-category any/all tag match, in each combobox's popover footer

The tag 'Match: Any/All' toggle was a single GLOBAL setting that only ever
affected topics, shown as its own row under the Topic widget. Make it
per-category — language, topic, and 'your tags' each get their own independent
any/all — and move it into each filter combobox's popover footer (like
SortControl's direction segment), reclaiming the standalone Match row.

Backend: the feed query already groups selected tags by category; replace the
single tag_mode with per-category tag_mode_language/topic/other params and apply
the conjunctive (HAVING all) branch per category. Mirrored across filtered_query,
feed_summary.passes(), and the /facets drill-down exclusion (kept in lockstep;
equivalence test updated to tag_mode_topic). No schema change.

Frontend: FeedFilters.tagMode -> tagModes {language,topic,other} (pure leaf
lib/api/tagModes with a coerceTagModes migration for the legacy global value);
serialized to the API and the shareable URL per category (legacy ?tagmode=and
still resolves into the topic slot); saved-view blobs migrate on read. Combobox
gains an optional matchMode/onMatchModeChange footer segment, shown once 2+
values are picked; the three tag comboboxes wire it to their category.

Verified: backend feed tests green (12, incl. the count/facets equivalence net);
live, the footer appears at 2+ selections, toggles aria-pressed, serializes
tagmode_topic=and to the URL, and the request carries tag_mode_topic=and (200).
374 vitest, tsc, ruff, eslint (0 err).
This commit is contained in:
2026-08-22 03:48:23 +02:00
parent 8f08073564
commit 829cadf5fb
15 changed files with 139 additions and 53 deletions
+2
View File
@@ -31,6 +31,8 @@ import { setupApi } from "./api/setup";
export type * from "./api/setup";
import { feedApi } from "./api/feed";
export type * from "./api/feed";
export type * from "./api/tagModes";
export { DEFAULT_TAG_MODES, TAG_CATEGORIES, coerceTagModes } from "./api/tagModes";
import { channelsApi } from "./api/channels";
export type * from "./api/channels";
import { syncApi } from "./api/sync";
+7 -2
View File
@@ -2,6 +2,7 @@
// the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay
// module-local (no consumer names them). Only touches core's req/beacon.
import { req, beacon } from "./core";
import { TAG_CATEGORIES, type TagModes } from "./tagModes";
// Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and
// "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position),
@@ -54,7 +55,8 @@ export interface FeedResponse {
export interface FeedFilters {
tags: number[];
tagMode: "or" | "and";
/** Per-category tag match (language / topic / other each independently any/all). */
tagModes: TagModes;
q: string;
sort: string;
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
@@ -83,7 +85,10 @@ export interface FeedFilters {
function filterParams(f: FeedFilters): URLSearchParams {
const p = new URLSearchParams();
f.tags.forEach((t) => p.append("tags", String(t)));
p.set("tag_mode", f.tagMode);
// Only emit the categories set to "and"; the backend defaults each to "or".
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
}
if (f.q) p.set("q", f.q);
p.set("scope", f.scope);
p.set("show_normal", String(f.includeNormal));
+24
View File
@@ -0,0 +1,24 @@
// Per-category tag match — the small, dependency-free vocabulary shared by the feed filter model, the
// URL (de)serializer, the sidebar comboboxes, and the saved-view migration. Kept in its own leaf (no
// imports) so node-env tests and any module can pull it in without dragging the HTTP/i18n machinery.
/** A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL. */
export type TagMode = "or" | "and";
/** The three tag categories, each with its OWN independent match mode. */
export type TagCategory = "language" | "topic" | "other";
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" };
/** 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 {
const r = (raw ?? {}) as { tagModes?: Partial<TagModes>; tagMode?: unknown };
const legacyTopic = r.tagMode === "and" ? "and" : undefined;
const pick = (cat: TagCategory): TagMode => {
const v = r.tagModes?.[cat];
if (v === "and" || v === "or") return v;
return cat === "topic" && legacyTopic ? "and" : "or";
};
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
}
+1 -1
View File
@@ -12,7 +12,7 @@ const PRESETS: DatePreset[] = [
const base = (over: Partial<FeedFilters> = {}): FeedFilters => ({
tags: [],
tagMode: "or",
tagModes: { language: "or", topic: "or", other: "or" },
q: "",
sort: "date",
scope: "my",
+2 -2
View File
@@ -12,7 +12,7 @@ import {
filtersToParams,
paramsToFilters,
} from "./urlState";
import type { FeedFilters } from "./api";
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
function setLocation(search: string) {
window.history.replaceState(null, "", `/app${search}`);
@@ -23,7 +23,7 @@ function queryOf(url: string): URLSearchParams {
const DEFAULTS: FeedFilters = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
+14 -4
View File
@@ -4,7 +4,7 @@
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import type { FeedFilters } from "./api";
import { TAG_CATEGORIES, type FeedFilters } from "./api";
const KEYS = [
"q",
@@ -16,7 +16,10 @@ const KEYS = [
"shorts",
"live",
"tags",
"tagmode",
"tagmode_language",
"tagmode_topic",
"tagmode_other",
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
"channel",
"maxage",
"from",
@@ -38,7 +41,9 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
if (f.includeShorts) p.set("shorts", "1");
if (f.includeLive) p.set("live", "1");
if (f.tags.length) p.set("tags", f.tags.join(","));
if (f.tagMode === "and") p.set("tagmode", "and");
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
}
if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
if (f.dateFrom) p.set("from", f.dateFrom);
@@ -84,7 +89,12 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
.split(",")
.map((s) => Number(s))
.filter((n) => Number.isInteger(n));
if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or";
// Per-category tag match; a legacy global `tagmode=and` (topic-only) migrates into the topic slot.
f.tagModes = {
language: params.get("tagmode_language") === "and" ? "and" : "or",
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
other: params.get("tagmode_other") === "and" ? "and" : "or",
};
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
f.maxAgeDays = num(params.get("maxage"));
f.dateFrom = params.get("from") || undefined;
+2 -2
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type FeedFilters } from "./api";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
import { parseSort } from "./feedSort";
/** One narrowing the user has applied, with the label to show and the way to undo just this one. */
@@ -14,7 +14,7 @@ export interface ActiveFilter {
/** What the feed looks like with nothing applied — the baseline every "active" check reads against. */
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
sort: "newest",
includeNormal: true,