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:
@@ -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));
|
||||
|
||||
@@ -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") };
|
||||
}
|
||||
Reference in New Issue
Block a user