diff --git a/VERSION b/VERSION index 6e79cb10..598d667c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.72.1 \ No newline at end of file +0.73.0 \ No newline at end of file diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index 87aa2be9..80e72d3b 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -100,6 +100,11 @@ def serialize(row) -> dict: } +def _tag_visible(user: User): + """A ChannelTag row this user may see: a shared system tag (user_id NULL) or their own.""" + return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) + + def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]: """Group tag ids by their category (language/topic/other). The single source for the category split shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the @@ -118,6 +123,7 @@ def filtered_query( *, tags: list[int], tag_modes: dict[str, str], + untagged: bool = False, channel_ids: list[str], q: str | None, min_duration: int | None, @@ -294,7 +300,7 @@ def filtered_query( # selections but still honours the other categories' filters). if exclude_tag_category: by_category.pop(exclude_tag_category, None) - visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) + visible = _tag_visible(user) for category, ids in by_category.items(): if tag_modes.get(category, "or") == "and" and len(ids) > 1: sub = ( @@ -307,6 +313,18 @@ def filtered_query( sub = select(ChannelTag.channel_id).where(ChannelTag.tag_id.in_(ids), visible) query = query.where(Video.channel_id.in_(sub)) + # "No tag" filter (item B): keep only channels the USER has not personally tagged in the channel + # manager — i.e. channels with no ChannelTag row owned by this user (user_id == me, source "user"). + # System/auto tags (user_id NULL) don't count: this is "channels I haven't tagged yet". It's a plain + # channel-level narrowing (like channel_ids/date), NOT a tag-category selection, so feed_summary + # leaves it applied in the base pass and needs no mirror in passes(). + if untagged: + query = query.where( + Video.channel_id.notin_( + select(ChannelTag.channel_id).where(ChannelTag.user_id == user.id) + ) + ) + # Content type: Normal / Shorts / Live·Upcoming as a union of enabled types. # Explicit Watched/Saved/Hidden views show every type so nothing goes missing. explicit_view = show in ("watched", "hidden") @@ -355,8 +373,9 @@ def feed_summary(db: Session, user: User, **params) -> dict: within, a category in "and" mode requires ALL its selected tags).""" tag_modes = params.get("tag_modes", {}) - # ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel - # is present to facet over. + # ONE pass: per-channel video counts for the base filter, the positive tag filter stripped so every + # candidate channel is present to facet over. The "No tag" (untagged) filter is a plain channel-level + # narrowing, so it STAYS applied here (not stripped) and the facets naturally drill down within it. base, _rank = filtered_query(db, user, **{**params, "tags": []}) ch_col = base.with_only_columns(Video.channel_id).subquery() channel_counts: dict[str, int] = dict( @@ -386,7 +405,8 @@ def feed_summary(db: Session, user: User, **params) -> dict: def passes(ch_tags: set[int], exclude_cat: str | None) -> bool: # The Python mirror of filtered_query's SQL tag block (AND across categories, OR within, - # topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there. + # topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there. (The "No tag" + # filter is applied in the base query above, so it needs no mirror here.) for cat, sel in selected_by_cat.items(): if cat == exclude_cat: continue @@ -417,6 +437,30 @@ def feed_summary(db: Session, user: User, **params) -> dict: return {"count": count, "facets": facets} +def feed_years(db: Session, user: User, **params) -> list[dict]: + """Per-year video counts for the current filter context (item 8 / E8·S2): powers the year control's + quick chips + the "more…" per-year-counts dialog. Every OTHER filter (tags, channels, content type, + search, watch state, scope) still applies, but the publish-date narrowing itself is DROPPED — + published_after/before and max_age_days — so the year list is the full set of years available and + switching years stays a live drill-down (the same principle as the tag facets dropping their own + category). One grouped query, newest year first.""" + base, _rank = filtered_query( + db, + user, + **{**params, "published_after": None, "published_before": None, "max_age_days": None}, + ) + pub = base.with_only_columns(Video.published_at.label("pub")).subquery() + # Bucket by UTC year to stay in lockstep with the year filter's boundaries (published_after/before + # are pinned to UTC in filtered_query). published_at is timestamptz, so a bare extract('year', …) + # would use the DB session timezone and, on a non-UTC session, bucket a video near a year boundary + # into a different year than the filter includes — the count and the drill-down feed would disagree. + year = func.extract("year", func.timezone("UTC", pub.c.pub)) + rows = db.execute( + select(year.label("y"), func.count()).group_by(year).order_by(year.desc()) + ).all() + return [{"year": int(y), "count": n} for y, n in rows if y is not None] + + # --- Keyset (cursor) pagination --------------------------------------------- # # Each sort is described as an ordered list of key columns (expression + direction diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index cedf2b21..2d6f3635 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -14,6 +14,7 @@ from app.feedquery import ( decode_cursor, encode_cursor, feed_summary, + feed_years, filtered_query, keyset_predicate, order_by, @@ -53,6 +54,8 @@ def _feed_params( tag_mode_language: str = "or", tag_mode_topic: str = "or", tag_mode_other: str = "or", + # "No tag" filter: keep only channels the user has not personally tagged (no user-owned ChannelTag). + untagged: bool = False, channel_ids: list[str] = Query(default=[]), q: str | None = None, min_duration: int | None = None, @@ -74,6 +77,7 @@ def _feed_params( "topic": tag_mode_topic, "other": tag_mode_other, }, + "untagged": untagged, "channel_ids": channel_ids, "q": q, "min_duration": min_duration, @@ -203,6 +207,19 @@ def get_feed_summary( return feed_summary(db, user, **params) +@router.get("/feed/years") +def get_feed_years( + params: dict = Depends(_feed_params), + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Per-year video counts for the year control (item 8 / E8·S2): the quick recent-year chips and the + "more…" per-year-counts dialog. Every filter EXCEPT the publish-date narrowing applies, so picking a + year re-queries the feed but not this list. Response: {"years": [{"year": , "count": }]}, + newest year first.""" + return {"years": feed_years(db, user, **params)} + + class VideoStateIn(BaseModel): status: str diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index b1008acd..0000cb7c 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -8,10 +8,22 @@ from app.auth import current_user from app.db import get_db from app.models import ChannelTag, Tag, User from app.routes._common import owned_or_404 +from app.sync.tagsuggest import compute_tag_suggestions router = APIRouter(prefix="/api/tags", tags=["tags"]) +@router.get("/suggestions") +def tag_suggestions( + user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict[str, list[dict]]: + """Suggested user tags for this user's still-untagged subscribed channels, learned from the + auto-tag pattern of the channels they HAVE tagged (see app.sync.tagsuggest). Read-only — the + Channel Manager shows these as one-tap-accept chips; accepting posts to the normal attach-tag + endpoint. Keyed by channel id; channels with no suggestion are omitted.""" + return compute_tag_suggestions(db, user) + + @router.get("") def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]: rows = db.execute( diff --git a/backend/app/sync/tagsuggest.py b/backend/app/sync/tagsuggest.py new file mode 100644 index 00000000..14d0f7e6 --- /dev/null +++ b/backend/app/sync/tagsuggest.py @@ -0,0 +1,131 @@ +"""Suggest a user's own manual tags for channels they haven't tagged yet. + +The signal is the auto-tagger's *topic* output: each channel carries ``source='auto'`` topic +ChannelTags (see ``autotag.py``). For every user tag T the user has applied to enough channels, +we learn which topics are *characteristic* of T (present in a high fraction of the channels the +user tagged T), then suggest T for any still-untagged channel whose own topics cover enough of +that profile. + +Language auto-tags are deliberately EXCLUDED from the signal: they are near-universal within a +language ("English" is on most channels), so they don't discriminate — including them floods the +result (measured on real data: every untagged channel got a suggestion, most on a shared language +alone). Topic overlap is what makes "you tagged similar channels this way" meaningful. + +Purely read-only and deterministic — the Channel Manager surfaces the result as one-tap-accept +"ghost" chips; nothing is written here (D4 = suggest-and-confirm, no silent auto-apply). The +thresholds are tuning constants rather than admin knobs on purpose: they mirror how ``autotag.py`` +keeps its mappings inline, and sysconfig only stores int/str/bool (no float), so a knob would mean +storing percentages — not worth the surface for values an operator has no reason to tune per-install. +""" + +from collections import Counter + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import ChannelTag, Subscription, Tag, User + +# A user tag needs at least this many manually-tagged example channels before we trust its +# profile — fewer is noise (one gaming-cooking channel shouldn't make "cooking" mean "gaming"). +MIN_EXAMPLES = 3 +# A topic counts as *characteristic* of a user tag when it appears in ≥60% of that tag's example +# channels. High enough that an incidental shared topic (one channel's stray category) doesn't +# enter the profile, low enough to survive a couple of outliers in a small example set. +CHAR_SUPPORT = 0.6 +# Suggest tag T for channel C when C's topics cover ≥60% of T's characteristic topics. The +# intersection-over-profile shape means a partial match scores proportionally: 1 topic of a +# 2-topic profile = 0.5 (dropped), 2 of 3 = 0.67 (kept). Tuned against real data to surface +# strong topical matches without flooding — 0.5 let a single shared topic carry a suggestion. +SUGGEST_THRESHOLD = 0.6 +# A suggestion at/above this score is "high confidence" — a full-profile topic match — and is the +# only kind the UI's "Accept all confident" action touches. Never applied silently (see docstring). +AUTOAPPLY_THRESHOLD = 0.9 +# Cap suggestions per channel so a busy channel's chip row stays legible. +MAX_PER_CHANNEL = 3 + + +def compute_tag_suggestions(db: Session, user: User) -> dict[str, list[dict]]: + """Map each of the user's still-untagged subscribed channels to suggested user tags. + + Returns ``{channel_id: [{"tag_id": int, "confidence": float}, ...]}`` — ordered best-first, + capped at ``MAX_PER_CHANNEL``. Channels with no suggestion are omitted (empty dict when the + user has no tag with a usable profile). Deterministic for a fixed DB state. + """ + # Candidate universe: the channels this user is subscribed to. `subscribed` (incl. hidden) is the + # SIGNAL set — a hidden channel is still a valid example / topic-feature source, since hiding only + # affects the feed, not the tagging pattern. `hidden` is excluded as a suggestion TARGET below (the + # Channel Manager shows ghost chips only on non-hidden rows, so suggesting for hidden channels would + # inflate the header count past what's shown and let bulk-accept tag channels the user can't see). + subs = db.execute( + select(Subscription.channel_id, Subscription.hidden).where(Subscription.user_id == user.id) + ).all() + subscribed: set[str] = {cid for cid, _ in subs} + hidden: set[str] = {cid for cid, h in subs if h} + if not subscribed: + return {} + + # The user's manual tag links: tag_id -> example channels, and each channel's user-tag set (so + # a channel with ANY user tag is excluded from "untagged"). Intersect with `subscribed`: unsubscribe + # deletes the Subscription but NOT the source='user' ChannelTag, so a tagged-then-unsubscribed + # channel would otherwise stay an "example" — inflating a profile's CHAR_SUPPORT denominator while + # contributing zero features (features are scanned over `subscribed` only), dropping characteristic + # topics. Keeping examples ⊆ subscribed makes the support fraction honest. + examples: dict[int, set[str]] = {} + user_tagged: set[str] = set() + for tag_id, cid in db.execute( + select(ChannelTag.tag_id, ChannelTag.channel_id).where( + ChannelTag.user_id == user.id, ChannelTag.source == "user" + ) + ).all(): + if cid not in subscribed: + continue + examples.setdefault(tag_id, set()).add(cid) + user_tagged.add(cid) + + # Topic features per channel: the auto TOPIC tags on the candidate channels. Language auto-tags + # are joined out (Tag.category) — they don't discriminate (see the module docstring). + features: dict[str, set[int]] = {} + for cid, tid in db.execute( + select(ChannelTag.channel_id, ChannelTag.tag_id) + .join(Tag, Tag.id == ChannelTag.tag_id) + .where( + ChannelTag.user_id.is_(None), + ChannelTag.source == "auto", + ChannelTag.channel_id.in_(subscribed), + Tag.category == "topic", + ) + ).all(): + features.setdefault(cid, set()).add(tid) + + # Characteristic-feature profile per user tag with enough examples. + profiles: dict[int, set[int]] = {} + for tag_id, ex in examples.items(): + if len(ex) < MIN_EXAMPLES: + continue + counts: Counter[int] = Counter() + for cid in ex: + counts.update(features.get(cid, ())) + threshold = CHAR_SUPPORT * len(ex) + chars = {f for f, c in counts.items() if c >= threshold} + if chars: + profiles[tag_id] = chars + if not profiles: + return {} + + suggestions: dict[str, list[dict]] = {} + for cid in subscribed: + if cid in hidden or cid in user_tagged: # hidden → no chips shown; tagged → not "untagged" + continue + cfeat = features.get(cid) + if not cfeat: + continue + scored = [(tag_id, len(cfeat & chars) / len(chars)) for tag_id, chars in profiles.items()] + hits = [(tid, s) for tid, s in scored if s >= SUGGEST_THRESHOLD] + if not hits: + continue + # Best score first; tag_id as a stable tie-break so the order is deterministic. + hits.sort(key=lambda x: (-x[1], x[0])) + suggestions[cid] = [ + {"tag_id": tid, "confidence": round(s, 3)} for tid, s in hits[:MAX_PER_CHANNEL] + ] + return suggestions diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py index 90c197b5..af190e8c 100644 --- a/backend/tests/test_feed_views.py +++ b/backend/tests/test_feed_views.py @@ -162,11 +162,154 @@ _SUMMARY_CASES = [ "show=all&tags={O1}&tags={O2}&tag_mode_other=and", "show=all&tags={U1}", # user-owned tag (visibility == user.id branch) "show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category + # "No tag" filter (item B): channels with no USER tag. It stays applied in the base pass, so the + # facet/count lockstep must still hold with it on (alone, and combined with a positive tag filter). + "show=all&untagged=true", + "show=all&untagged=true&tags={L1}", "show=all&q=clip", "show=unwatched", ] +def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user, make_user): + """The "No tag" filter (item B): `untagged=true` keeps only channels with NO tag the USER personally + attached (no user-owned ChannelTag). System/auto tags (user_id NULL) don't count, and another user's + tag on the same channel doesn't either.""" + user = make_user(email="untagged@example.com") + other = make_user(email="someone-else@example.com") + mine = Tag(user_id=user.id, name="Fav", category="other") + sys_topic = Tag(user_id=None, name="Tech", category="topic") + theirs = Tag(user_id=other.id, name="TheirTag", category="other") + db.add_all([mine, sys_topic, theirs]) + chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chMine", "chSys", "chNone")} + db.add_all(chans.values()) + db.flush() + # chMine: the user's own tag → tagged. chSys: only a system tag + another user's tag → still + # "untagged" for THIS user. chNone: nothing at all → untagged. + db.add(ChannelTag(channel_id="chMine", tag_id=mine.id, user_id=user.id, source="user")) + db.add(ChannelTag(channel_id="chSys", tag_id=sys_topic.id, user_id=None, source="auto")) + db.add(ChannelTag(channel_id="chSys", tag_id=theirs.id, user_id=other.id, source="user")) + for cid in chans: + db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False)) + db.add(_video(f"{cid}v", cid, f"{cid} clip")) + db.commit() + client = as_user(user) + + # Off: every subscribed channel shows. + assert _feed_ids(client, "show=all") == {"chMinev", "chSysv", "chNonev"} + # On: only channels the user has NOT personally tagged (a system tag / another user's tag don't count). + assert _feed_ids(client, "show=all&untagged=true") == {"chSysv", "chNonev"} + + +def test_feed_years_counts_ignore_the_date_narrowing_but_honour_other_filters( + db, as_user, make_user +): + """The year facet (item 8 / E8·S2): `/api/feed/years` returns a per-year video count, newest first, + with the publish-date narrowing DROPPED (so switching years stays a drill-down) but every other + filter still applied (here: a channel pick).""" + user = make_user(email="years@example.com") + chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chX", "chY")} + db.add_all(chans.values()) + db.flush() + # chX: 2 videos in 2024, 1 in 2022; chY: 1 in 2023 (should drop once we scope to chX). + spread = { + "chX": [(2024, 1), (2024, 2), (2022, 5)], + "chY": [(2023, 3)], + } + for cid, dates in spread.items(): + db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False)) + for i, (yr, day) in enumerate(dates): + db.add( + Video( + id=f"{cid}v{i}", + channel_id=cid, + published_at=datetime(yr, 1, day, tzinfo=timezone.utc), + title=f"{cid} clip {i}", + is_short=False, + live_status="none", + via_search=False, + via_explore=False, + ) + ) + db.commit() + client = as_user(user) + + # All channels: every year present, counted, newest first. + resp = client.get("/api/feed/years?show=all") + assert resp.status_code == 200, resp.text + assert resp.json()["years"] == [ + {"year": 2024, "count": 2}, + {"year": 2023, "count": 1}, + {"year": 2022, "count": 1}, + ] + + # A channel filter narrows the year list; a date narrowing is ignored (the point of the facet — so + # a selected year never zeroes out the others). Scoping to chX drops 2023, keeps 2024 (×2) + 2022. + resp = client.get("/api/feed/years?show=all&channel_ids=chX&published_after=2024-01-01") + assert resp.status_code == 200, resp.text + assert resp.json()["years"] == [ + {"year": 2024, "count": 2}, + {"year": 2022, "count": 1}, + ] + + +def test_feed_years_buckets_by_utc_year_under_a_non_utc_session(db, make_user): + """feed_years must bucket by UTC year to stay in lockstep with the year filter's UTC-pinned + published_after/before bounds. published_at is timestamptz; a bare extract('year', …) would use the + DB session timezone, so a video just before midnight UTC on New Year's Eve would bucket into the + wrong (local) year on a non-UTC session and disagree with the feed the year chip drills into. Called + directly on this session (the HTTP client uses a separate connection SET TIME ZONE wouldn't reach).""" + from sqlalchemy import text + + from app.feedquery import feed_years + + user = make_user(email="tz-years@example.com") + db.add(Channel(id="chTZ", title="chTZ", subscriber_count=1)) + db.add(Subscription(user_id=user.id, channel_id="chTZ", priority=0, hidden=False)) + # 2023-12-31 23:30 UTC — a positive-offset session tz local-shifts this into 2024. + db.add( + Video( + id="tzv0", + channel_id="chTZ", + published_at=datetime(2023, 12, 31, 23, 30, tzinfo=timezone.utc), + title="NYE clip", + is_short=False, + live_status="none", + via_search=False, + via_explore=False, + ) + ) + db.commit() + + params: dict = { + "tags": [], + "tag_modes": {"language": "or", "topic": "or", "other": "or"}, + "untagged": False, + "channel_ids": [], + "q": None, + "min_duration": None, + "max_duration": None, + "max_age_days": None, + "published_after": None, + "published_before": None, + "show_normal": True, + "include_shorts": False, + "include_live": False, + "show": "all", + "scope": "my", + "library_source": "organic", + } + db.execute( + text("SET TIME ZONE 'Asia/Tokyo'") + ) # +9: pushes the video's local wall-clock into 2024 + try: + years = feed_years(db, user, **params) + finally: + db.execute(text("SET TIME ZONE 'UTC'")) # SET (not SET LOCAL) survives rollback — reset it + # UTC bucketing keeps the boundary video in 2023; a bare session-tz extract would report 2024. + assert years == [{"year": 2023, "count": 1}] + + @pytest.mark.parametrize("case", _SUMMARY_CASES) def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case): # /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces, diff --git a/backend/tests/test_tag_suggest.py b/backend/tests/test_tag_suggest.py new file mode 100644 index 00000000..3552a2c7 --- /dev/null +++ b/backend/tests/test_tag_suggest.py @@ -0,0 +1,286 @@ +"""DB-backed regression (E8·S4): the manual-tag-pattern suggester. + +compute_tag_suggestions learns which auto TOPIC tags are *characteristic* of each user tag (from the +channels the user tagged), then proposes that tag for still-untagged subscribed channels whose own +topics cover ≥60% of the profile. The cases below pin the behaviours that would silently ship a +useless suggester if they broke: the min-examples floor, the intersection-over-profile fraction that +keeps a weak partial match out, the "language is not a signal" exclusion (its inclusion once flooded +every channel with a suggestion), and the "already tagged → excluded" scope. Deterministic — no +network, pure DB state. + +Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. +""" + +from app.models import Channel, ChannelTag, Subscription, Tag, User +from app.sync.tagsuggest import ( + AUTOAPPLY_THRESHOLD, + CHAR_SUPPORT, + MAX_PER_CHANNEL, + SUGGEST_THRESHOLD, + compute_tag_suggestions, +) + + +def _auto(db, name, category="topic"): + tag = Tag(user_id=None, name=name, category=category) + db.add(tag) + db.flush() + return tag + + +def _subscribe(db, user, ch_id, *, auto_tag_ids=(), title=None, hidden=False): + """A subscribed channel carrying the given auto-tags (source='auto', user_id NULL).""" + db.add(Channel(id=ch_id, title=title or ch_id)) + db.add(Subscription(user_id=user.id, channel_id=ch_id, hidden=hidden)) + for tid in auto_tag_ids: + db.add(ChannelTag(channel_id=ch_id, tag_id=tid, user_id=None, source="auto")) + + +def test_suggests_a_user_tag_for_a_channel_matching_its_topic_profile(db): + me = User(email="me@example.com") + db.add(me) + db.flush() + edu = _auto(db, "Education") + film = _auto(db, "Film") + eng = _auto(db, "English", category="language") # a language auto-tag → NOT part of the signal + docu = Tag(user_id=me.id, name="docu", category="other") + db.add(docu) + db.flush() + + # Three example channels the user tagged "docu", all Education+Film (+English). The topic profile + # is {Education, Film}; the English language tag is excluded from the profile. + for i in range(3): + cid = f"UC_ex{i}" + _subscribe(db, me, cid, auto_tag_ids=[edu.id, film.id, eng.id]) + db.add(ChannelTag(channel_id=cid, tag_id=docu.id, user_id=me.id, source="user")) + + # An untagged subscribed channel sharing the full topic profile → full-confidence suggestion. + _subscribe(db, me, "UC_match", auto_tag_ids=[edu.id, film.id, eng.id]) + # An untagged channel sharing ONLY the language → no topic overlap at all, so no suggestion. This + # is the anti-flood guarantee: a shared language must never carry a suggestion on its own. + _subscribe(db, me, "UC_english_only", auto_tag_ids=[eng.id]) + db.commit() + + out = compute_tag_suggestions(db, me) + + assert "UC_match" in out + assert out["UC_match"][0]["tag_id"] == docu.id + # Both profile topics present → full confidence, i.e. an "accept all confident" hit. + assert out["UC_match"][0]["confidence"] >= AUTOAPPLY_THRESHOLD + assert "UC_english_only" not in out + # Example channels are already tagged → excluded from suggestions. + assert "UC_ex0" not in out + + +def test_a_tag_below_the_example_floor_produces_no_profile(db): + me = User(email="me2@example.com") + db.add(me) + db.flush() + edu = _auto(db, "Education") + niche = Tag(user_id=me.id, name="niche", category="other") + db.add(niche) + db.flush() + + # Only TWO example channels for "niche" (< MIN_EXAMPLES=3) → no profile, so no suggestion even + # for a perfectly-matching untagged channel. + for i in range(2): + cid = f"UC_niche{i}" + _subscribe(db, me, cid, auto_tag_ids=[edu.id]) + db.add(ChannelTag(channel_id=cid, tag_id=niche.id, user_id=me.id, source="user")) + _subscribe(db, me, "UC_would_match", auto_tag_ids=[edu.id]) + db.commit() + + assert compute_tag_suggestions(db, me) == {} + + +def test_partial_topic_overlap_is_a_fraction_of_the_profile(db): + # Guards the intersection-over-profile shape at the SUGGEST_THRESHOLD boundary: a 3-topic profile + # matched by 1 topic scores 0.33 (dropped); matched by 2 scores 0.67 (kept). Pins the threshold + # so a change to it can't silently widen/narrow what "a suggestion" means. + assert SUGGEST_THRESHOLD == 0.6 + me = User(email="me3@example.com") + db.add(me) + db.flush() + a, b, c = _auto(db, "A"), _auto(db, "B"), _auto(db, "C") + tag = Tag(user_id=me.id, name="triad", category="other") + db.add(tag) + db.flush() + for i in range(3): + cid = f"UC_triad{i}" + _subscribe(db, me, cid, auto_tag_ids=[a.id, b.id, c.id]) + db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=me.id, source="user")) + _subscribe(db, me, "UC_one_of_three", auto_tag_ids=[a.id]) # 1/3 = 0.33 < 0.6 → dropped + _subscribe(db, me, "UC_two_of_three", auto_tag_ids=[a.id, b.id]) # 2/3 = 0.67 ≥ 0.6 → kept + db.commit() + + out = compute_tag_suggestions(db, me) + assert "UC_one_of_three" not in out + assert "UC_two_of_three" in out + assert out["UC_two_of_three"][0]["tag_id"] == tag.id + assert out["UC_two_of_three"][0]["confidence"] == round(2 / 3, 3) + + +def test_language_alone_is_never_a_profile_even_when_it_is_the_only_shared_tag(db): + # The anti-flood invariant WITH TEETH. Every example channel shares only a language; each carries a + # DIFFERENT topic, so no topic reaches CHAR_SUPPORT → the topic-only profile is empty → no + # suggestion. If language were ever admitted to the signal the profile would collapse to {language} + # and the language-only channel would score 1.0 and be suggested — the exact flood the exclusion + # prevents. (test_suggests_… can't catch a regression here: there the language merely enlarges an + # already topic-driven profile, so removing the exclusion flips no assertion.) + me = User(email="lang@example.com") + db.add(me) + db.flush() + eng = _auto(db, "English", category="language") + topics = [_auto(db, f"Topic{i}") for i in range(3)] + lang = Tag(user_id=me.id, name="english-stuff", category="other") + db.add(lang) + db.flush() + for i in range(3): + cid = f"UC_lang_ex{i}" + _subscribe(db, me, cid, auto_tag_ids=[eng.id, topics[i].id]) + db.add(ChannelTag(channel_id=cid, tag_id=lang.id, user_id=me.id, source="user")) + _subscribe(db, me, "UC_lang_only", auto_tag_ids=[eng.id]) + db.commit() + + # No characteristic topic → no profile → nothing suggested for anyone (language never carries it). + assert compute_tag_suggestions(db, me) == {} + + +def test_char_support_admits_a_two_thirds_topic_and_excludes_a_one_third_topic(db): + # Pins the characteristic-profile threshold (CHAR_SUPPORT) at its PARTIAL-support boundary: across 3 + # example channels a topic in 2/3 (0.67 ≥ 0.6) enters the profile, one in 1/3 (0.33) does not. The + # other fixtures give every topic full 3/3 support, so a regression demanding full support (or + # flipping ≥ to >) would ship green — this is the only case that exercises fractional support. + assert CHAR_SUPPORT == 0.6 + me = User(email="chr@example.com") + db.add(me) + db.flush() + p, q, r = _auto(db, "P"), _auto(db, "Q"), _auto(db, "R") + mix = Tag(user_id=me.id, name="mix", category="other") + db.add(mix) + db.flush() + # p in 3/3 (kept), q in 2/3 (kept, 0.67), r in 1/3 (excluded, 0.33) → profile = {p, q}. + example_topics = [[p.id, q.id, r.id], [p.id, q.id], [p.id]] + for i, topics in enumerate(example_topics): + cid = f"UC_mix_ex{i}" + _subscribe(db, me, cid, auto_tag_ids=topics) + db.add(ChannelTag(channel_id=cid, tag_id=mix.id, user_id=me.id, source="user")) + _subscribe(db, me, "UC_pq", auto_tag_ids=[p.id, q.id]) # {p,q}/{p,q} = 1.0 → suggested + _subscribe(db, me, "UC_only_p", auto_tag_ids=[p.id]) # 1/2 = 0.5 < 0.6 → dropped + _subscribe(db, me, "UC_p_and_r", auto_tag_ids=[p.id, r.id]) # {p}/{p,q} = 0.5 → dropped + db.commit() + + out = compute_tag_suggestions(db, me) + assert "UC_pq" in out and out["UC_pq"][0]["confidence"] == 1.0 + # q (2/3) IS in the profile: p alone covers only half of it. Would be a full 1.0 match if the + # builder wrongly required 3/3 support (profile would collapse to {p}). + assert "UC_only_p" not in out + # r (1/3) is NOT in the profile: {p,r} covers only p. Would score 0.67 (suggested) if r wrongly + # entered the profile (a downward drift of CHAR_SUPPORT). + assert "UC_p_and_r" not in out + + +def test_suggestions_are_capped_and_ordered_best_first(db): + # A channel matching many user-tag profiles gets at most MAX_PER_CHANNEL suggestions, ordered best + # score first, with the weakest dropped by the cap. No other fixture matches >1 profile, so the sort + # and the cap are both no-ops there — dropping either ships green. Four DISTINCT confidences keep the + # order unambiguous. (The exact-tie tag_id tie-break in the sort key is a determinism detail, not + # asserted here: on a stable sort it isn't catchable without relying on unspecified DB row order.) + me = User(email="cap@example.com") + db.add(me) + db.flush() + + def profile_tag(name, n_topics): + """A user tag whose characteristic profile is exactly n_topics (3 identical example channels).""" + topics = [_auto(db, f"{name}_t{i}") for i in range(n_topics)] + tag = Tag(user_id=me.id, name=name, category="other") + db.add(tag) + db.flush() + for i in range(3): + cid = f"UC_{name}_ex{i}" + _subscribe(db, me, cid, auto_tag_ids=[t.id for t in topics]) + db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=me.id, source="user")) + return tag, topics + + t0, top0 = profile_tag("p0", 1) # profile size 1 + t1, top1 = profile_tag("p1", 5) # profile size 5 + t2, top2 = profile_tag("p2", 10) # profile size 10 + t3, top3 = profile_tag("p3", 10) # profile size 10 + + # One untagged channel matching all four at distinct confidences: + # t0: 1/1 = 1.00 + # t1: 4/5 = 0.80 + # t2: 7/10 = 0.70 + # t3: 6/10 = 0.60 → a valid hit (≥ SUGGEST_THRESHOLD) but the weakest, dropped by the cap of 3 + matched = ( + [top0[0].id] + + [t.id for t in top1[:4]] + + [t.id for t in top2[:7]] + + [t.id for t in top3[:6]] + ) + _subscribe(db, me, "UC_many", auto_tag_ids=matched) + db.commit() + + hits = compute_tag_suggestions(db, me)["UC_many"] + assert len(hits) == MAX_PER_CHANNEL # 4 matches, capped to 3 + assert [h["tag_id"] for h in hits] == [t0.id, t1.id, t2.id] # best score first + assert [h["confidence"] for h in hits] == [1.0, 0.8, 0.7] + assert t3.id not in [ + h["tag_id"] for h in hits + ] # the weakest match (0.60) is the one the cap drops + + +def test_unsubscribed_example_channel_does_not_inflate_the_profile_denominator(db): + # Unsubscribe deletes the Subscription but NOT the source='user' ChannelTag, so a tagged-then- + # unsubscribed channel must NOT count as a profile example: it contributes no topic features (those + # are scanned over subscribed channels only) yet would inflate CHAR_SUPPORT's denominator and drop a + # genuinely characteristic topic. Here 'X' is characteristic at 2/3 of the SUBSCRIBED examples + # (threshold 1.8, count 2 → kept), but if the unsubscribed 4th example counted, threshold 2.4 would + # drop it and suppress the suggestion. + me = User(email="unsub@example.com") + db.add(me) + db.flush() + x, y = _auto(db, "X"), _auto(db, "Y") + wood = Tag(user_id=me.id, name="wood", category="other") + db.add(wood) + db.flush() + # 3 subscribed examples: 2 carry topic X, 1 carries only Y → X support 2/3 among subscribed. + for cid, topic in [("UC_w0", x), ("UC_w1", x), ("UC_w2", y)]: + _subscribe(db, me, cid, auto_tag_ids=[topic.id]) + db.add(ChannelTag(channel_id=cid, tag_id=wood.id, user_id=me.id, source="user")) + # A 4th channel tagged "wood" but NO LONGER subscribed (channel + user tag, no Subscription row). + db.add(Channel(id="UC_gone", title="UC_gone")) + db.add(ChannelTag(channel_id="UC_gone", tag_id=wood.id, user_id=me.id, source="user")) + _subscribe( + db, me, "UC_cand", auto_tag_ids=[x.id] + ) # untagged X match → should be suggested "wood" + db.commit() + + out = compute_tag_suggestions(db, me) + assert "UC_cand" in out + assert out["UC_cand"][0]["tag_id"] == wood.id + + +def test_hidden_subscriptions_are_not_suggestion_targets(db): + # The Channel Manager renders suggestion ghost chips only on non-hidden rows, so a hidden channel + # must not be a suggestion target — otherwise the header count / bulk-accept would span channels with + # no visible chip. (Hidden channels still count as example/feature signal; only targeting is excluded.) + me = User(email="hid@example.com") + db.add(me) + db.flush() + z = _auto(db, "Z") + ztag = Tag(user_id=me.id, name="ztag", category="other") + db.add(ztag) + db.flush() + for i in range(3): + cid = f"UC_z{i}" + _subscribe(db, me, cid, auto_tag_ids=[z.id]) + db.add(ChannelTag(channel_id=cid, tag_id=ztag.id, user_id=me.id, source="user")) + # A hidden, untagged, perfectly-matching subscription → NOT a target; a visible one → suggested. + _subscribe(db, me, "UC_hidden", auto_tag_ids=[z.id], hidden=True) + _subscribe(db, me, "UC_visible", auto_tag_ids=[z.id]) + db.commit() + + out = compute_tag_suggestions(db, me) + assert "UC_visible" in out + assert "UC_hidden" not in out diff --git a/frontend/src/components/ChannelTags.suggest.dom.test.tsx b/frontend/src/components/ChannelTags.suggest.dom.test.tsx new file mode 100644 index 00000000..cefa69eb --- /dev/null +++ b/frontend/src/components/ChannelTags.suggest.dom.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +// +// Contract of the suggested-tag ghost chips (E8·S4): on an UNTAGGED channel, ChannelTagsControl renders +// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the +// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already +// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform +// and a ResizeObserver-driven fit measurer, so we stub the observer and wrap in a FloatingTree. +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { FloatingTree } from "@floating-ui/react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + // Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu"). + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: { name?: string }) => (opts?.name ? `${key}:${opts.name}` : key), + i18n: { language: "en" }, + }), + }; +}); + +import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags"; +import type { Tag } from "../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); + +afterEach(cleanup); + +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 suggestions: SuggestedTag[] = [ + { tag: docu, confidence: 0.95 }, + { tag: history, confidence: 0.6 }, +]; + +function renderControl(props: Partial[0]> = {}) { + const onAccept = vi.fn(); + const onDismiss = vi.fn(); + render( + + {}} + onCreate={() => {}} + suggestions={suggestions} + onAcceptSuggestion={onAccept} + onDismissSuggestion={onDismiss} + {...props} + /> + , + ); + return { onAccept, onDismiss }; +} + +describe("ChannelTagsControl suggested ghost chips", () => { + it("renders a ghost chip per suggestion and accepts on click", () => { + const { onAccept } = renderControl(); + // Role query excludes the aria-hidden fit-measurer copy; the name carries the interpolated tag. + fireEvent.click(screen.getByRole("button", { name: "channels.tags.acceptSuggestion:docu" })); + expect(onAccept).toHaveBeenCalledWith(docu.id); + }); + + it("dismisses via the × without accepting", () => { + const { onAccept, onDismiss } = renderControl(); + const dismissButtons = screen.getAllByRole("button", { + name: "channels.tags.dismissSuggestion", + }); + // Two visible ghost chips (the measurer's copies are aria-hidden → excluded); order matches the + // suggestions array, so the second × is the "history" suggestion. + expect(dismissButtons).toHaveLength(2); + fireEvent.click(dismissButtons[1]!); + expect(onDismiss).toHaveBeenCalledWith(history.id); + expect(onAccept).not.toHaveBeenCalled(); + }); + + it("shows the attached lane (no ghost chips) once the channel carries a user tag", () => { + renderControl({ attachedIds: [docu.id] }); + // A tagged channel is not "untagged" → the ghost lane is not rendered at all (the server excludes + // tagged channels from suggestions; the control mirrors that with its attached-XOR-ghost branch). + expect( + screen.queryAllByRole("button", { name: "channels.tags.dismissSuggestion" }), + ).toHaveLength(0); + }); +}); diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 3ed2017a..030f40ec 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Check, Plus } from "lucide-react"; +import { Check, Plus, Sparkles, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { type Tag } from "../lib/api"; import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover"; @@ -40,6 +40,157 @@ function TagChip({ tag, onClick, title }: TagChipProps) { ); } +// A suggested user tag (E8·S4): a dashed "ghost" chip the user hasn't applied yet, learned from +// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing +// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the +// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.) +export interface SuggestedTag { + tag: Tag; + confidence: number; +} + +function SuggestedChip({ + suggestion, + onAccept, + onDismiss, +}: { + suggestion: SuggestedTag; + onAccept: () => void; + onDismiss: () => void; +}) { + const { t } = useTranslation(); + const pct = Math.round(suggestion.confidence * 100); + return ( + + + + + ); +} + +const GHOST_PILL = + "shrink-0 text-3xs px-1.5 py-0.5 rounded-full border border-dashed border-accent/50 text-accent/90"; + +/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover + * reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip + * lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what + * stops a 2nd suggestion from being clipped mid-word right next to the edit "+". */ +function SuggestedChipsLane({ + suggestions, + onAccept, + onDismiss, +}: { + suggestions: SuggestedTag[]; + onAccept: (tagId: number) => void; + onDismiss: (tagId: number) => void; +}) { + const { t } = useTranslation(); + const rowRef = useRef(null); + const chipRefs = useRef>([]); + const pillRef = useRef(null); + const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|"); + const visible = useChipOverflowFit({ + rowRef, + chipsRef: chipRefs, + pillRef, + deps: key, + initial: suggestions.length, + decide: (g) => + fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }), + }); + const hidden = suggestions.length - visible; + + const [overflowOpen, setOverflowOpen] = useState(false); + const overflowPop = useAnchoredPopover({ + open: overflowOpen, + onOpenChange: setOverflowOpen, + mode: "hover", + placement: "bottom", + }); + useEffect(() => { + if (hidden === 0 && overflowOpen) setOverflowOpen(false); + }, [hidden, overflowOpen]); + + return ( +
+ {/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */} +
+ {suggestions.map((s, i) => ( + { + chipRefs.current[i] = el; + }} + className="inline-flex" + > + {}} onDismiss={() => {}} /> + + ))} + + +{suggestions.length} + +
+ +
+ {suggestions.slice(0, visible).map((s) => ( + onAccept(s.tag.id)} + onDismiss={() => onDismiss(s.tag.id)} + /> + ))} + {hidden > 0 && ( + + )} +
+ + {/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */} + + {suggestions.slice(visible).map((s) => ( + onAccept(s.tag.id)} + onDismiss={() => onDismiss(s.tag.id)} + /> + ))} + +
+ ); +} + /** The editor body: toggle the user's existing tags on/off for this channel, plus an inline * "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel * detail page in Part 2; internal for now.) */ @@ -139,6 +290,9 @@ export function ChannelTagsControl({ onToggle, onCreate, onChipClick, + suggestions, + onAcceptSuggestion, + onDismissSuggestion, bounded = true, }: { userTags: Tag[]; @@ -147,6 +301,11 @@ export function ChannelTagsControl({ onCreate: (name: string) => void; /** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */ onChipClick?: (tagId: number, name: string) => void; + /** Manager only (E8·S4): suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost + * chips after the (empty) attached row. Omit on the detail page. */ + suggestions?: SuggestedTag[]; + onAcceptSuggestion?: (tagId: number) => void; + onDismissSuggestion?: (tagId: number) => void; /** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the * table (the manager). Set false where there is free width (the channel detail header), so tags * fit inline instead of needlessly collapsing into "+N". */ @@ -154,6 +313,9 @@ export function ChannelTagsControl({ }) { const { t } = useTranslation(); const attached = userTags.filter((tg) => attachedIds.includes(tg.id)); + // Suggestions only surface for channels with no user tag yet, but guard anyway: never suggest a tag + // the channel already carries (an optimistic accept adds it before the query refetches). + const shownSuggestions = (suggestions ?? []).filter((s) => !attachedIds.includes(s.tag.id)); // Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their // natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem @@ -214,49 +376,66 @@ export function ChannelTagsControl({
- {/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. Each - chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its right edge; - the pill is the sample the hook reserves room for. */} -
- {attached.map((tg, i) => ( - { - chipRefs.current[i] = el; - }} - className="inline-flex" + {/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the + attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged + channels only, and accepting one removes the channel from the suggestion set — so a channel + never shows both, and each lane gets the full cell width instead of clipping the other. */} + {attached.length === 0 && shownSuggestions.length > 0 ? ( + onAcceptSuggestion?.(tagId)} + onDismiss={(tagId) => onDismissSuggestion?.(tagId)} + /> + ) : ( + <> + {/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. + Each chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its + right edge; the pill is the sample the hook reserves room for. */} +
- - - ))} - - +{attached.length} - -
+ {attached.map((tg, i) => ( + { + chipRefs.current[i] = el; + }} + className="inline-flex" + > + + + ))} + + +{attached.length} + +
-
- {attached.slice(0, visible).map((tg) => ( - onChipClick(tg.id, tg.name) : undefined} - title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined} - /> - ))} - {hidden > 0 && ( - - )} -
+
+ {attached.slice(0, visible).map((tg) => ( + onChipClick(tg.id, tg.name) : undefined} + title={ + onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined + } + /> + ))} + {hidden > 0 && ( + + )} +
+ + )} - - {userTags.map((tg) => ( - - ))} - {tagFilter.length > 0 && ( - - )} -
- {actionButtons} + {/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds + overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the + "…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */} +
+ {/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate + "Manage tags" button; the tooltip explains what tags are + that it's clickable. */} + + + + + {tagFilter.length > 0 && ( + + )} + {/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a + one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the + rest. Hidden when there's nothing to suggest. */} + {visibleSuggestions.length > 0 && ( +
+ + + + {t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })} + + + {highConfidence.length > 0 && ( + + )} +
+ )} +
{actionButtons}
)} diff --git a/frontend/src/components/DateFilterSelect.tsx b/frontend/src/components/DateFilterSelect.tsx index 9df1cd33..f4fe6587 100644 --- a/frontend/src/components/DateFilterSelect.tsx +++ b/frontend/src/components/DateFilterSelect.tsx @@ -151,6 +151,7 @@ export default function DateFilterSelect({ ...filters, dateFrom: e.target.value || undefined, maxAgeDays: undefined, + year: undefined, }) } className={dateInputCls} @@ -169,6 +170,7 @@ export default function DateFilterSelect({ ...filters, dateTo: e.target.value || undefined, maxAgeDays: undefined, + year: undefined, }) } className={dateInputCls} diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index 8ef6d8ea..40dfd30c 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -13,6 +13,7 @@ import { api, getActiveAccount, coerceTagModes, + coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters, } from "../lib/api"; @@ -39,6 +40,7 @@ import { useNavigation } from "./NavigationProvider"; const DEFAULT_FILTERS: FeedFilters = { tags: [], tagModes: DEFAULT_TAG_MODES, + untagged: false, channelIds: [], q: "", sort: "newest", @@ -74,7 +76,12 @@ export function loadAccountFilters(id: number | null): FeedFilters { // (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it. if (hasStored && fKey) { const raw = readJSON>(fKey, {}); - return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) }; + return { + ...DEFAULT_FILTERS, + ...raw, + tagModes: coerceTagModes(raw), + untagged: coerceUntagged(raw), + }; } return { ...DEFAULT_FILTERS }; } diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx index c17b9dd3..53d264d2 100644 --- a/frontend/src/components/SavedViewsProvider.tsx +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -14,6 +14,7 @@ import { api, coercePlexFilters, coerceTagModes, + coerceUntagged, getActiveAccount, plexFiltersSignature, type FeedFilters, @@ -42,6 +43,7 @@ export const asFilters = (raw: FeedFilters): FeedFilters => ({ tags: raw.tags ?? [], channelIds: raw.channelIds ?? [], tagModes: coerceTagModes(raw), + untagged: coerceUntagged(raw), }); type ViewSource = "feed" | "plex"; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6c74d5b3..efe3baa2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { SlidersHorizontal } from "lucide-react"; -import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api"; +import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../lib/api"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { useDebounced } from "../lib/useDebounced"; import { useActiveFilters } from "../lib/useActiveFilters"; @@ -15,6 +15,7 @@ import { type PanelItem } from "./PanelGroups"; import { type MediaSourceKind } from "../lib/mediaSource"; import ChannelPicker from "./ChannelPicker"; import DateFilterSelect from "./DateFilterSelect"; +import YearFilter from "./YearFilter"; import Combobox, { type ComboboxOption } from "./Combobox"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useChannelsActions } from "./ChannelsProvider"; @@ -104,12 +105,48 @@ export default function Sidebar({ const setTagMode = (category: TagCategory, mode: "or" | "and") => setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } }); + // The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to + // channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually + // exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears + // it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here. + function userTagsCombo(): ReactNode { + const noneOn = !!filters.untagged; + const inList = (id: number) => userTags.some((tg) => tg.id === id); + const onChange = (next: string[]) => { + if (next.includes(NO_TAG_SENTINEL) && !noneOn) { + setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true }); + } else { + const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number); + setFilters({ + ...filters, + tags: [...filters.tags.filter((id) => !inList(id)), ...reals], + untagged: false, + }); + } + }; + return ( + setTagMode("other", m)} + /> + ); + } + // The badge counts exactly what the chip row above the feed shows — same list, so they can't // disagree about what's applied. const activeCount = useActiveFilters(filters, setFilters).length; const available: Record = { date: true, + year: true, channel: true, language: languages.length > 0, topic: topics.length > 0, @@ -124,7 +161,9 @@ export default function Sidebar({ return ( ); - case "language": { + case "year": + return ; + case "language": return ( setTagMode("language", m)} /> ); - } - case "topic": { + case "topic": return ( setTagMode("topic", m)} /> ); - } - case "tags": { + case "tags": // The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the - // body is just the picker. - return ( - setTagSelection(userTags, next)} - emptyText={t("sidebar.noMatchingTags")} - matchMode={filters.tagModes.other} - onMatchModeChange={(m) => setTagMode("other", m)} - /> - ); - } + // body is just the picker — user tags plus the "No tag" sentinel. + return userTagsCombo(); default: return null; } diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx new file mode 100644 index 00000000..e2efd945 --- /dev/null +++ b/frontend/src/components/TagFilterChips.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { fitChipsWithPill } from "../lib/chipOverflow"; +import { useChipOverflowFit } from "../lib/useChipOverflowFit"; +import { filterChipClass } from "./FilterChipGroup"; +import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover"; + +// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose +// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses +// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by +// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions +// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…" +// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N". + +export interface FilterChip { + key: string; + label: string; + active: boolean; + onToggle: () => void; +} + +function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) { + return ( + + ); +} + +export default function TagFilterChips({ + chips, + className, +}: { + chips: FilterChip[]; + className?: string; +}) { + const { t } = useTranslation(); + const rowRef = useRef(null); + const chipRefs = useRef>([]); + const pillRef = useRef(null); + // Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's + // width via the `.pill` recipe, so an aria-pressed flip must re-measure too). + const key = chips.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}`).join("|"); + + const visible = useChipOverflowFit({ + rowRef, + chipsRef: chipRefs, + pillRef, + deps: key, + initial: chips.length, + decide: (g) => + fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }), + }); + + const hidden = chips.length - visible; + const [open, setOpen] = useState(false); + const pop = useAnchoredPopover({ + open, + onOpenChange: setOpen, + mode: "menu", + placement: "bottom-end", + }); + // If nothing overflows anymore, the "+N" trigger unmounts — force the popover closed so it can't + // linger against a gone reference. + useEffect(() => { + if (hidden === 0 && open) setOpen(false); + }, [hidden, open]); + + const moreLabel = t("channels.tags.moreCount", { count: hidden }); + + return ( +
+ {/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can + read each chip's right edge, the rem gap and the pill width off real boxes. */} +
+ {chips.map((c, i) => ( + { + chipRefs.current[i] = el; + }} + className="inline-flex" + > + {c.label} + + ))} + + +{chips.length} + +
+ +
+ {chips.slice(0, visible).map((c) => ( + + ))} + {hidden > 0 && ( + + )} +
+ + + {chips.slice(visible).map((c) => ( + + ))} + +
+ ); +} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index e502722c..f50c573f 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -17,6 +17,7 @@ import { import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import DownloadButton from "./DownloadButton"; +import VideoInfoCard from "./VideoInfoCard"; import clsx from "clsx"; import type { Video, VideoStatus } from "../lib/api"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; @@ -105,6 +106,17 @@ function Actions({ ); } +// The hover-infobox trigger skins (E8·S3). The ⓘ is hover-revealed and dropped on touch (`reveal-hover` +// + the group opacity toggle), and lives OUTSIDE the six-button action budget — as a thumbnail-corner +// overlay for the thumbnailed views, and inline beside the actions for the one-line row. +const INFO_REVEAL = + "reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity"; +const INFO_OVERLAY = clsx( + INFO_REVEAL, + "absolute bottom-1.5 left-1.5 z-10 rounded-md bg-black/70 p-1 text-white hover:bg-black/85", +); +const INFO_INLINE = clsx(INFO_REVEAL, "rounded-md p-1.5 text-muted hover:bg-surface hover:text-fg"); + // Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click // keep the native "open youtube.com in a new tab" behavior via the underlying href. function openInApp( @@ -312,6 +324,10 @@ function Thumb({ )} )} + {/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the + corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the + rest of the hover chrome, dropped on touch. */} + {/* Resume progress bar (sits at the very bottom, under the duration badge). */} {pct > 0 && (
@@ -330,7 +346,7 @@ function Thumb({ // Keep this table and the classes in step; each number is measured against the worst case in the // LIBRARY and in BOTH languages — see the row's own comment for where each comes from. const ROW_COL = { - actions: 192, // w-48 — six buttons at 28 + five 4px gaps = 188 + actions: 224, // w-48 (six buttons: 6×28 + 5×4 = 188) + gap-1 + the 28px inline info trigger channel: 128, // w-32 meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps views: 64, // w-16 @@ -508,8 +524,13 @@ function VideoCard({ className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")} > {/* Actions lead: this is where the eye already is (the title is the thing you read), so - they're the shortest pointer trip from it. */} -
{actions}
+ they're the shortest pointer trip from it. This row has no thumbnail to host the info + trigger, so it sits inline right after the actions (still outside their six-button + budget). ROW_COL.actions already counts its width. */} +
+
{actions}
+ +
{/* Everything else is shrink-0, so the title is the only thing that can give — which means once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop from the right as the row narrows, least important first: a title with no date beats a diff --git a/frontend/src/components/VideoInfoCard.dom.test.tsx b/frontend/src/components/VideoInfoCard.dom.test.tsx new file mode 100644 index 00000000..a175a3b0 --- /dev/null +++ b/frontend/src/components/VideoInfoCard.dom.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +// +// Contract of the feed-card hover infobox (E8·S3): the trigger stays closed until opened; on open the +// header (full title + channel) renders IMMEDIATELY from the Video we already hold, while the +// description (videoDetail) and the channel's tags (channelDetail.tag_ids resolved against the tag +// list) fill in from their on-demand queries; an empty description falls back to the "no description" +// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui (hover mode → open via +// focus), so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient. +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { FloatingTree } from "@floating-ui/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +const videoDetail = vi.fn(); +const channelDetail = vi.fn(); +const tags = vi.fn(); +vi.mock("../lib/api", () => ({ + api: { + videoDetail: (...a: unknown[]) => videoDetail(...a), + channelDetail: (...a: unknown[]) => channelDetail(...a), + tags: (...a: unknown[]) => tags(...a), + }, +})); + +import VideoInfoCard from "./VideoInfoCard"; +import type { Video } from "../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const video = (over: Partial