release: 0.73.0 (feature/e8-feed-filtering)

This commit is contained in:
2026-08-26 05:28:11 +02:00
44 changed files with 2236 additions and 131 deletions
+1 -1
View File
@@ -1 +1 @@
0.72.1
0.73.0
+48 -4
View File
@@ -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
+17
View File
@@ -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": <int>, "count": <int>}]},
newest year first."""
return {"years": feed_years(db, user, **params)}
class VideoStateIn(BaseModel):
status: str
+12
View File
@@ -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(
+131
View File
@@ -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
+143
View File
@@ -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,
+286
View File
@@ -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
@@ -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<typeof import("react-i18next")>();
// 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<Parameters<typeof ChannelTagsControl>[0]> = {}) {
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
<FloatingTree>
<ChannelTagsControl
userTags={[docu, history]}
attachedIds={[]}
onToggle={() => {}}
onCreate={() => {}}
suggestions={suggestions}
onAcceptSuggestion={onAccept}
onDismissSuggestion={onDismiss}
{...props}
/>
</FloatingTree>,
);
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);
});
});
+221 -42
View File
@@ -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 (
<span className="shrink-0 inline-flex items-center rounded-full border border-dashed border-accent/50 text-accent/90 text-2xs font-medium pl-1 pr-0.5 py-0.5 max-w-36">
<button
type="button"
onClick={onAccept}
title={t("channels.tags.suggestedHint", { pct })}
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
>
<Sparkles className="w-2.5 h-2.5 shrink-0" />
<span className="truncate">{suggestion.tag.name}</span>
</button>
<button
type="button"
onClick={onDismiss}
title={t("channels.tags.dismissSuggestion")}
aria-label={t("channels.tags.dismissSuggestion")}
className="shrink-0 ml-0.5 rounded-full text-accent/60 hover:text-danger transition"
>
<X className="w-2.5 h-2.5" />
</button>
</span>
);
}
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<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(null);
const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|");
const visible = useChipOverflowFit<number>({
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 (
<div className="relative flex items-center gap-1 min-w-0 flex-1">
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{suggestions.map((s, i) => (
<span
key={s.tag.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
</span>
))}
<span ref={pillRef} className={GHOST_PILL}>
+{suggestions.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{suggestions.slice(0, visible).map((s) => (
<SuggestedChip
key={s.tag.id}
suggestion={s}
onAccept={() => onAccept(s.tag.id)}
onDismiss={() => onDismiss(s.tag.id)}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${GHOST_PILL} hover:text-accent transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
>
{suggestions.slice(visible).map((s) => (
<SuggestedChip
key={s.tag.id}
suggestion={s}
onAccept={() => onAccept(s.tag.id)}
onDismiss={() => onDismiss(s.tag.id)}
/>
))}
</PopoverSurface>
</div>
);
}
/** 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({
<div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
>
{/* 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. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{attached.map((tg, i) => (
<span
key={tg.id}
ref={(el) => {
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 ? (
<SuggestedChipsLane
suggestions={shownSuggestions}
onAccept={(tagId) => 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. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
<TagChip tag={tg} />
</span>
))}
<span ref={pillRef} className={pillClass}>
+{attached.length}
</span>
</div>
{attached.map((tg, i) => (
<span
key={tg.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<TagChip tag={tg} />
</span>
))}
<span ref={pillRef} className={pillClass}>
+{attached.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{attached.slice(0, visible).map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{attached.slice(0, visible).map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={
onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined
}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
</>
)}
<button
ref={editPop.refs.setReference}
+108 -41
View File
@@ -4,7 +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, type ManagedChannel } from "../lib/api";
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { Sparkles } from "lucide-react";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
@@ -15,10 +16,12 @@ 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";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import FilterChipGroup from "./FilterChipGroup";
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./Pager";
@@ -164,10 +167,37 @@ export default function Channels() {
),
),
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side.
onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
// the learned pattern (E8·S4).
onSuccess: (id) => {
qc.invalidateQueries({ queryKey: qk.channel(id) });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
},
// 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). 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(),
onSuccess: (r: { subscriptions?: number }) => {
@@ -217,11 +247,34 @@ export default function Channels() {
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
const toggleTagFilter = (tagId: number) => {
const id = String(tagId);
const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id];
const base = tagFilter.filter((x) => x !== NO_TAG_SENTINEL);
const next = base.includes(id) ? base.filter((x) => x !== id) : [...base, id];
setFilters({ ...filters, tags: next });
};
// The "No tag" chip: filter the table to channels you haven't personally tagged yet. Exclusive with
// real tag chips (clicking it drops them); the Tags column's test reads the sentinel (channelColumns).
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
const toggleNoTagFilter = () =>
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
// The tag-filter chips (user tags + the "No tag" sentinel), fed to TagFilterChips which folds any
// overflow into a "+N" pill so the row stays one line and the trailing "…" actions can't wrap.
const tagFilterChips: FilterChip[] = [
...userTags.map((tg) => ({
key: `t${tg.id}`,
label: tg.name,
active: tagFilter.includes(String(tg.id)),
onToggle: () => toggleTagFilter(tg.id),
})),
{
key: NO_TAG_SENTINEL,
label: t("channels.tags.noTag"),
active: noTagOn,
onToggle: toggleNoTagFilter,
},
];
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
@@ -295,6 +348,9 @@ export default function Channels() {
toggleTag,
createAndAttachTag,
onFilterByTag,
suggestionsFor,
onAcceptSuggestion: acceptSuggestion,
onDismissSuggestion: dismissSuggestion,
});
// Status chips sit in the table's controls row (next to paging) — compact and close to
@@ -426,41 +482,52 @@ export default function Channels() {
{tabs}
{view === "subscribed" && (
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
search lives in the shared top SearchBar, tag create/delete in the manager. */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex flex-wrap items-center gap-1.5">
{/* 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. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<button
onClick={() => setTagManagerOpen(true)}
className="mr-1 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
>
<Pencil className="h-3 w-3" />
{t("channels.tags.label")}
</button>
</Tooltip>
{userTags.map((tg) => (
<button
key={tg.id}
onClick={() => toggleTagFilter(tg.id)}
aria-pressed={tagFilter.includes(String(tg.id))}
className={filterChipClass}
>
{tg.name}
</button>
))}
{tagFilter.length > 0 && (
<button
onClick={() => setFilters({ ...filters, tags: [] })}
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
</div>
{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. */}
<div className="flex items-center gap-2 min-w-0">
{/* 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. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<button
onClick={() => setTagManagerOpen(true)}
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
>
<Pencil className="h-3 w-3" />
{t("channels.tags.label")}
</button>
</Tooltip>
<TagFilterChips chips={tagFilterChips} className="flex-1" />
{tagFilter.length > 0 && (
<button
onClick={() => setFilters({ ...filters, tags: [] })}
className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
{/* 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 && (
<div className="shrink-0 flex items-center gap-1.5">
<Tooltip hint={t("channels.tags.suggestionsHint")}>
<span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-accent/50 px-2 py-1 text-xs text-accent/90">
<Sparkles className="h-3 w-3" />
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
</span>
</Tooltip>
{highConfidence.length > 0 && (
<button
onClick={acceptAllHighConfidence}
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
>
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
</button>
)}
</div>
)}
<div className="shrink-0">{actionButtons}</div>
</div>
</div>
)}
@@ -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}
@@ -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<Partial<FeedFilters>>(fKey, {});
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
return {
...DEFAULT_FILTERS,
...raw,
tagModes: coerceTagModes(raw),
untagged: coerceUntagged(raw),
};
}
return { ...DEFAULT_FILTERS };
}
@@ -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";
+45 -19
View File
@@ -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 (
<Combobox
label={t("sidebar.widget.tags")}
options={[
{ value: NO_TAG_SENTINEL, label: t("sidebar.noTagOption") },
...visibleChips(userTags).map(tagOption),
]}
selected={noneOn ? [NO_TAG_SENTINEL] : selectedIn(userTags)}
onChange={onChange}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.other}
onMatchModeChange={(m) => 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<string, boolean> = {
date: true,
year: true,
channel: true,
language: languages.length > 0,
topic: topics.length > 0,
@@ -124,7 +161,9 @@ export default function Sidebar({
return (
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
);
case "language": {
case "year":
return <YearFilter filters={filters} setFilters={setFilters} />;
case "language":
return (
<Combobox
label={t("sidebar.widget.language")}
@@ -136,8 +175,7 @@ export default function Sidebar({
onMatchModeChange={(m) => setTagMode("language", m)}
/>
);
}
case "topic": {
case "topic":
return (
<Combobox
label={t("sidebar.widget.topic")}
@@ -149,22 +187,10 @@ export default function Sidebar({
onMatchModeChange={(m) => 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 (
<Combobox
label={t("sidebar.widget.tags")}
options={visibleChips(userTags).map(tagOption)}
selected={selectedIn(userTags)}
onChange={(next) => 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;
}
+128
View File
@@ -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 (
<button
type="button"
onClick={chip.onToggle}
aria-pressed={chip.active}
className={`${filterChipClass}${extra ? ` ${extra}` : ""}`}
>
{chip.label}
</button>
);
}
export default function TagFilterChips({
chips,
className,
}: {
chips: FilterChip[];
className?: string;
}) {
const { t } = useTranslation();
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(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<number>({
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 (
<div className={`relative flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* 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. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5 whitespace-nowrap"
>
{chips.map((c, i) => (
<span
key={c.key}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<span className={filterChipClass}>{c.label}</span>
</span>
))}
<span ref={pillRef} className={filterChipClass}>
+{chips.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<Chip key={c.key} chip={c} extra="shrink-0" />
))}
{hidden > 0 && (
<button
ref={pop.refs.setReference}
type="button"
aria-label={moreLabel}
className={`${filterChipClass} shrink-0 ${open ? "border-accent text-accent" : ""}`}
{...pop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
<PopoverSurface
popover={pop}
ariaLabel={moreLabel}
className="glass-menu flex max-w-[22rem] flex-wrap items-center gap-1.5 rounded-xl p-2 shadow-lg animate-[popIn_0.16s_ease]"
>
{chips.slice(visible).map((c) => (
<Chip key={c.key} chip={c} />
))}
</PopoverSurface>
</div>
);
}
+24 -3
View File
@@ -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({
)}
</div>
)}
{/* 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. */}
<VideoInfoCard video={video} className={INFO_OVERLAY} />
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
@@ -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. */}
<div className="shrink-0 w-48">{actions}</div>
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. */}
<div className="shrink-0 flex items-center gap-1">
<div className="w-48">{actions}</div>
<VideoInfoCard video={video} className={INFO_INLINE} />
</div>
{/* 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
@@ -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<typeof import("react-i18next")>();
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<Video> = {}): Video =>
({
id: "v1",
title: "The full untruncated title",
channel_id: "c1",
channel_title: "Test Channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/c1",
published_at: "2026-02-24T00:00:00Z",
thumbnail_url: null,
duration_seconds: 754,
view_count: 1512,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 0,
saved: false,
watch_url: "https://youtu.be/v1",
...over,
}) as Video;
const open = (v: Video = video()) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoInfoCard video={v} className="info-trigger" />
</FloatingTree>
</QueryClientProvider>,
);
fireEvent.focus(screen.getByRole("button", { name: "card.info" }));
};
describe("VideoInfoCard", () => {
it("renders a trigger, panel closed until opened (no fetch at rest)", () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoInfoCard video={video()} className="info-trigger" />
</FloatingTree>
</QueryClientProvider>,
);
expect(screen.getByRole("button", { name: "card.info" })).toBeTruthy();
expect(screen.queryByText("The full untruncated title")).toBeNull();
// `enabled: open` gates the queries — nothing fired while closed.
expect(videoDetail).not.toHaveBeenCalled();
expect(channelDetail).not.toHaveBeenCalled();
});
it("shows the header immediately from the Video, then fills in description + tags on load", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: "A longer description snippet." });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [1, 3] });
tags.mockResolvedValue([
{ id: 1, name: "English", category: "language" },
{ id: 2, name: "Unused", category: "topic" },
{ id: 3, name: "Astronomy", category: "topic" },
]);
open();
// Header is from the Video prop — present without waiting on any query.
expect(screen.getByText("The full untruncated title")).toBeTruthy();
expect(screen.getByText("Test Channel")).toBeTruthy();
// Description + only the channel's own tags (1 + 3, not 2) once the queries resolve.
expect(await screen.findByText("A longer description snippet.")).toBeTruthy();
expect(await screen.findByText("English")).toBeTruthy();
expect(await screen.findByText("Astronomy")).toBeTruthy();
expect(screen.queryByText("Unused")).toBeNull();
});
it("falls back to the no-description line when the video has none", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: null });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [] });
tags.mockResolvedValue([]);
open();
expect(await screen.findByText("card.noDescription")).toBeTruthy();
});
});
+143
View File
@@ -0,0 +1,143 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Info } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type Video } from "../lib/api";
import { formatDate, formatDuration, formatViews } from "../lib/format";
import { linkify } from "../lib/linkify";
import Avatar from "./Avatar";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
// small ⓘ button — a thumbnail-corner overlay in the four thumbnailed views, an inline button beside
// the actions in the one-line row (see the caller). It reveals only with the other hover controls on a
// mouse and is dropped entirely on touch (`.reveal-hover`), where a tap already plays/resumes and a
// hover card would only be an invisible trap. Hover mode also opens it on keyboard focus, so it stays
// reachable by Tab.
//
// The card face already carries title/channel/date/duration/views, so the box's real added value is
// the FULL title (the card clamps to 2 lines) plus the description + tags, which aren't in the feed
// list payload. Both are fetched ON DEMAND on open (like AboutCell): the description via
// `videoDetail`, the tags by resolving the channel's `tag_ids` (from `channelDetail`, shared with the
// channel page's cache) against the tag list. Until they land the header still shows from the Video
// we already have, so the box is never empty.
//
// The caller owns the trigger's placement + reveal via `className`: a thumbnail-corner overlay for the
// four thumbnailed views, an inline button beside the actions for the one-line row. The action ROW
// itself is budgeted to exactly six buttons (a seventh wraps in every view — see VideoCard's ROW_COL),
// so the trigger deliberately lives OUTSIDE it.
export default function VideoInfoCard({
video,
className,
}: {
video: Video;
/** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules
* live here so each host can match its surface (overlay vs inline). */
className?: string;
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "hover",
placement: "bottom-end",
offset: 6,
// A touch of open intent (so brushing past the button while reaching another control doesn't
// pop it), a longer close grace so the cursor can travel into the interactive card.
hoverDelay: { open: 140, close: 160 },
fitHeight: true,
});
const { data: detail } = useQuery({
queryKey: qk.videoDetail(video.id),
queryFn: () => api.videoDetail(video.id),
enabled: open,
staleTime: 5 * 60_000,
});
const { data: channel } = useQuery({
queryKey: qk.channel(video.channel_id),
queryFn: () => api.channelDetail(video.channel_id),
enabled: open && !!video.channel_id,
staleTime: 5 * 60_000,
});
const { data: tags } = useQuery({
queryKey: qk.tags(),
queryFn: () => api.tags(),
enabled: open,
staleTime: 5 * 60_000,
});
// The channel's tags (`tag_ids`), in the channel's own id order, resolved to names via the tag list.
// These are the channel-tags the channel actually carries; an unknown id (a just-deleted tag) drops.
const tagNames =
channel && tags
? channel.tag_ids
.map((id) => tags.find((tg) => tg.id === id))
.filter((tg): tg is NonNullable<typeof tg> => !!tg)
: [];
const description = detail?.description?.trim();
const metaBits = [
video.view_count != null ? `${formatViews(video.view_count)} ${t("card.views")}` : null,
video.duration_seconds != null ? formatDuration(video.duration_seconds) : null,
video.published_at ? formatDate(video.published_at, i18n.language) : null,
].filter(Boolean);
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
// both preventDefault (no navigation) and stopPropagation (no card open); harmless inline too.
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
aria-label={t("card.info")}
title={t("card.info")}
className={className}
>
<Info className="w-4 h-4" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={t("card.info")}
className="glass w-[340px] max-w-[92vw] overflow-y-auto rounded-lg px-3.5 py-3 animate-[fadeIn_0.12s_ease]"
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
>
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
<div className="mt-2 flex items-center gap-2">
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-6 h-6 rounded-full shrink-0"
/>
<span className="text-xs text-muted truncate">{video.channel_title}</span>
</div>
{metaBits.length > 0 && <p className="mt-1.5 text-xs text-muted">{metaBits.join(" · ")}</p>}
{description ? (
<p className="mt-2.5 text-xs leading-relaxed text-muted line-clamp-5 wrap-break-word">
{linkify(description)}
</p>
) : detail ? (
<p className="mt-2.5 text-xs italic text-muted">{t("card.noDescription")}</p>
) : null}
{tagNames.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tagNames.map((tg) => (
<span key={tg.id} className="glass-card px-2 py-0.5 rounded-full text-2xs">
{tg.name}
</span>
))}
</div>
)}
</PopoverSurface>
</>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ChevronDown } from "lucide-react";
import { qk } from "../lib/queryKeys";
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
// filters the feed to that calendar year (a whole-year published_after/before range, applied in the
// api layer); it's a single-select toggle (click the active year to clear it) and mutually exclusive
// with the Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
//
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
// own category.
const QUICK = 6; // recent-year chips shown before the "More…" overflow
export default function YearFilter({
filters,
setFilters,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
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,
dateTo: undefined,
};
const yearsQuery = useQuery({
queryKey: qk.feedYears(facetFilters),
queryFn: () => api.feedYears(facetFilters),
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const years = yearsQuery.data?.years ?? [];
const selected = filters.year;
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
const pick = (year?: number) =>
setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined });
// Quick chips = the most recent years, plus the selected year if it falls outside that window (so the
// active pick is always visible), re-sorted newest-first.
const quick = useMemo(() => {
const top = years.slice(0, QUICK);
if (selected != null && !top.some((y) => y.year === selected)) {
const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 };
return [...top, sel].sort((a, b) => b.year - a.year);
}
return top;
}, [years, selected]);
const sorted = useMemo(
() =>
[...years].sort((a, b) =>
sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year,
),
[years, sortByCount],
);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
});
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
if (years.length === 0) {
if (yearsQuery.isLoading) {
return (
<p className="text-muted px-1 py-1.5 text-xs" role="status" aria-live="polite">
{t("common.loading")}
</p>
);
}
if (yearsQuery.isError) {
return (
<p className="text-muted px-1 py-1.5 text-xs" role="alert">
{t("common.loadError")}
</p>
);
}
return <p className="text-muted px-1 py-1.5 text-xs">{t("sidebar.year.none")}</p>;
}
return (
<div className="flex flex-wrap gap-1.5">
{quick.map((y) => {
const on = selected === y.year;
return (
<button
key={y.year}
type="button"
aria-pressed={on}
onClick={() => pick(on ? undefined : y.year)}
className={filterChipClass}
>
<span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />}
</button>
);
})}
{years.length > quick.length && (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={t("sidebar.year.moreLabel")}
className={filterChipClass}
>
<span>{t("sidebar.year.more")}</span>
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.year.moreLabel")}
className="glass-menu min-w-44 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
<div className="ml-auto flex gap-1">
{(
[
["sortYear", false],
["sortCount", true],
] as const
).map(([key, v]) => (
<button
key={key}
type="button"
aria-pressed={sortByCount === v}
onClick={() => setSortByCount(v)}
className={`text-2xs rounded-md border px-2 py-0.5 transition ${
sortByCount === v
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
}`}
>
{t("sidebar.year." + key)}
</button>
))}
</div>
</div>
<div
className="max-h-64 overflow-y-auto no-scrollbar"
role="group"
aria-label={t("sidebar.year.moreLabel")}
>
{sorted.map((y) => {
const on = selected === y.year;
return (
<PopoverItem
key={y.year}
role="menuitemradio"
checked={on}
onSelect={() => {
pick(on ? undefined : y.year);
setOpen(false);
}}
label={String(y.year)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${on ? "bg-accent" : "bg-transparent"}`}
/>
<span className="flex-1 tabular-nums">{y.year}</span>
<span className="text-2xs text-muted shrink-0 tabular-nums">{y.count}</span>
</PopoverItem>
);
})}
</div>
</PopoverSurface>
</>
)}
</div>
);
}
@@ -5,8 +5,8 @@
// "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change
// which channels the top chips show.
import { describe, it, expect } from "vitest";
import { applySyncFilter } from "./channelColumns";
import { type ManagedChannel } from "../lib/api";
import { applySyncFilter, tagsColumnTest } from "./channelColumns";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
id: "c",
@@ -51,3 +51,23 @@ describe("applySyncFilter", () => {
expect(applySyncFilter(rows, "needsFull").map((r) => r.id)).toEqual(["recentOnly", "none"]);
});
});
describe("tagsColumnTest (item B — 'No tag')", () => {
const tagged = ch({ id: "tagged", tag_ids: [1, 2] });
const untagged = ch({ id: "untagged", tag_ids: [] });
it("the No-tag sentinel keeps only channels with no user tag", () => {
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL])).toBe(false);
expect(tagsColumnTest(untagged, [NO_TAG_SENTINEL])).toBe(true);
});
it("real tag values match ANY selected id (sentinel absent)", () => {
expect(tagsColumnTest(tagged, ["2"])).toBe(true);
expect(tagsColumnTest(tagged, ["3"])).toBe(false);
expect(tagsColumnTest(untagged, ["1"])).toBe(false);
});
it("the sentinel wins if it somehow co-exists with real ids", () => {
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false);
});
});
+21 -3
View File
@@ -2,14 +2,14 @@ import type { ComponentProps } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
import type { ManagedChannel } from "../lib/api";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
import type { Column } from "./DataTable";
import Tooltip from "./Tooltip";
import { PriorityStepper } from "./PriorityStepper";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import { ChannelTagsControl } from "./ChannelTags";
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
@@ -42,6 +42,14 @@ export function applySyncFilter(rows: ManagedChannel[], value: unknown): Managed
return rows;
}
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
return values.some((v) => c.tag_ids.includes(Number(v)));
}
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
// describes. Full detail stays in the hover tooltip.
@@ -158,6 +166,10 @@ export interface ChannelColumnDeps {
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
createAndAttachTag: (channelId: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void;
// E8·S4 — suggested user tags per channel (resolved to Tag + confidence), plus accept/dismiss.
suggestionsFor: (channelId: string) => SuggestedTag[];
onAcceptSuggestion: (channelId: string, tagId: number) => void;
onDismissSuggestion: (channelId: string, tagId: number) => void;
}
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
@@ -177,6 +189,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
toggleTag,
createAndAttachTag,
onFilterByTag,
suggestionsFor,
onAcceptSuggestion,
onDismissSuggestion,
} = deps;
return [
{
@@ -289,7 +304,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
test: tagsColumnTest,
},
render: (c) => (
<ChannelTagsControl
@@ -298,6 +313,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(c.id, name)}
onChipClick={onFilterByTag}
suggestions={suggestionsFor(c.id)}
onAcceptSuggestion={(tagId) => onAcceptSuggestion(c.id, tagId)}
onDismissSuggestion={(tagId) => onDismissSuggestion(c.id, tagId)}
/>
),
},
@@ -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,
};
}
+3 -1
View File
@@ -17,5 +17,7 @@
"restart": "Restart",
"play": "Play",
"stream": "stream",
"views": "views"
"views": "views",
"info": "Details",
"noDescription": "No description"
}
+17 -1
View File
@@ -51,7 +51,23 @@
"noneYet": "No tags yet — create one below.",
"create": "Create tag",
"createPlaceholder": "New tag…",
"createFailed": "Could not create the 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",
"suggestedHint": "Suggested from your tagging pattern ({{pct}}% match) — click to add",
"acceptSuggestion": "Add suggested tag {{name}}",
"dismissSuggestion": "Dismiss suggestion",
"suggestionsHint": "Tags suggested for your untagged channels, learned from how you've tagged similar ones. Click a ghost chip to add it, or × to dismiss.",
"suggestionsCount_one": "{{count}} suggestion",
"suggestionsCount_other": "{{count}} suggestions",
"acceptAllHigh_one": "Accept {{count}} confident",
"acceptAllHigh_other": "Accept {{count}} confident",
"acceptAllTitle": "Accept suggested tags",
"acceptAllConfirm_one": "Add the {{count}} high-confidence suggested tag to its channel?",
"acceptAllConfirm_other": "Add the {{count}} high-confidence suggested tags to their channels?",
"acceptAllConfirmCta": "Accept all"
},
"loading": "Loading channels…",
"empty": "No channels.",
+1
View File
@@ -3,6 +3,7 @@
"label": "Filtered by",
"remove": "Remove filter: {{name}}",
"contentNone": "No content types",
"untagged": "Untagged",
"count_one": "{{count}} filter",
"count_other": "{{count}} filters",
"moreCount_one": "{{count}} more filter",
+11 -1
View File
@@ -27,6 +27,7 @@
"clearDates": "clear dates",
"reshuffle": "Reshuffle",
"noMatchingTags": "No matching tags here",
"noTagOption": "No tag",
"manageTags": "Manage",
"widget": {
"savedviews": "Saved views",
@@ -37,7 +38,16 @@
"language": "Language",
"topic": "Topic",
"tags": "Your tags",
"channel": "Channel"
"channel": "Channel",
"year": "Year"
},
"year": {
"more": "More…",
"moreLabel": "All years",
"none": "No years yet",
"sortBy": "Sort",
"sortYear": "Year",
"sortCount": "Count"
},
"show": {
"unwatched": "Unwatched",
+3 -1
View File
@@ -17,5 +17,7 @@
"restart": "Elölről",
"play": "Lejátszás",
"stream": "stream",
"views": "megtekintés"
"views": "megtekintés",
"info": "Részletek",
"noDescription": "Nincs leírás"
}
+17 -1
View File
@@ -51,7 +51,23 @@
"noneYet": "Még nincs címke — hozz létre egyet lent.",
"create": "Címke létrehozása",
"createPlaceholder": "Új címke…",
"createFailed": "A címke létrehozása nem sikerült."
"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",
"suggestedHint": "A címkézési mintád alapján javasolt ({{pct}}% egyezés) — kattints a hozzáadáshoz",
"acceptSuggestion": "{{name}} javasolt címke hozzáadása",
"dismissSuggestion": "Javaslat elvetése",
"suggestionsHint": "A címkézetlen csatornáidhoz javasolt címkék, abból tanulva, hogyan címkézted a hasonlókat. Kattints egy szellem-chipre a hozzáadáshoz, vagy ×-re az elvetéshez.",
"suggestionsCount_one": "{{count}} javaslat",
"suggestionsCount_other": "{{count}} javaslat",
"acceptAllHigh_one": "{{count}} biztos elfogadása",
"acceptAllHigh_other": "{{count}} biztos elfogadása",
"acceptAllTitle": "Javasolt címkék elfogadása",
"acceptAllConfirm_one": "Hozzáadod a(z) {{count}} nagy magabiztosságú javasolt címkét a csatornájához?",
"acceptAllConfirm_other": "Hozzáadod a(z) {{count}} nagy magabiztosságú javasolt címkét a csatornáikhoz?",
"acceptAllConfirmCta": "Mind elfogadása"
},
"loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.",
+1
View File
@@ -3,6 +3,7 @@
"label": "Szűrve",
"remove": "Szűrő eltávolítása: {{name}}",
"contentNone": "Nincs tartalomtípus",
"untagged": "Címkézetlen",
"count_one": "{{count}} szűrő",
"count_other": "{{count}} szűrő",
"moreCount_one": "még {{count}} szűrő",
+11 -1
View File
@@ -27,6 +27,7 @@
"clearDates": "dátumok törlése",
"reshuffle": "Újrakeverés",
"noMatchingTags": "Nincs ide illő címke",
"noTagOption": "Nincs címke",
"manageTags": "Kezelés",
"widget": {
"savedviews": "Mentett nézetek",
@@ -37,7 +38,16 @@
"language": "Nyelv",
"topic": "Téma",
"tags": "Saját címkék",
"channel": "Csatorna"
"channel": "Csatorna",
"year": "Év"
},
"year": {
"more": "Több…",
"moreLabel": "Összes év",
"none": "Még nincs év",
"sortBy": "Rendezés",
"sortYear": "Év",
"sortCount": "Darab"
},
"show": {
"unwatched": "Nem nézett",
+7 -1
View File
@@ -32,7 +32,13 @@ 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";
export {
DEFAULT_TAG_MODES,
TAG_CATEGORIES,
NO_TAG_SENTINEL,
coerceTagModes,
coerceUntagged,
} from "./api/tagModes";
import { channelsApi } from "./api/channels";
export type * from "./api/channels";
import { syncApi } from "./api/sync";
+28 -3
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;
@@ -78,6 +81,16 @@ export interface FeedFilters {
maxDuration?: number;
dateFrom?: string;
dateTo?: string;
/** Filter-by-year (item 8): a single calendar year, expressed on the wire as a whole-year
* published_after/before range. Mutually exclusive with maxAgeDays/dateFrom/dateTo (all
* publish-date narrowings) — the setters clear the others, and `filterParams` lets year win. */
year?: number;
}
/** One year's video count for the year control's chips + "more…" dialog (item 8 / E8·S2). */
export interface YearCount {
year: number;
count: number;
}
// The filter half of the query (everything except paging/sort), shared by the feed and
@@ -89,6 +102,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));
@@ -96,9 +110,16 @@ function filterParams(f: FeedFilters): URLSearchParams {
p.set("include_live", String(f.includeLive));
p.set("show", f.show);
f.channelIds.forEach((id) => p.append("channel_ids", id));
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
if (f.dateFrom) p.set("published_after", f.dateFrom);
if (f.dateTo) p.set("published_before", f.dateTo);
// Year (item 8) is a whole-year publish-date range and wins over the maxAge/custom-range controls
// (they're mutually exclusive publish-date narrowings — the setters enforce it, this guards the wire).
if (f.year) {
p.set("published_after", `${f.year}-01-01`);
p.set("published_before", `${f.year}-12-31`);
} else {
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
if (f.dateFrom) p.set("published_after", f.dateFrom);
if (f.dateTo) p.set("published_before", f.dateTo);
}
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
// Only relevant in Library scope; the backend ignores it for "my". Default = hide search.
@@ -144,6 +165,10 @@ export const feedApi = {
// the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13).
feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record<string, number> }> =>
req(`/api/feed/summary?${filterParams(f).toString()}`),
// Per-year video counts for the year control (item 8 / E8·S2). The backend drops the publish-date
// narrowing, so callers key this on the date-stripped filters (picking a year mustn't refetch it).
feedYears: (f: FeedFilters): Promise<{ years: YearCount[] }> =>
req(`/api/feed/years?${filterParams(f).toString()}`),
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
// recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
setState: (id: string, status: string) =>
+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;
}
+9
View File
@@ -10,8 +10,17 @@ export interface Tag {
channel_count: number;
}
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern (E8·S4).
export interface TagSuggestion {
tag_id: number;
confidence: number;
}
// Keyed by channel id; channels with no suggestion are omitted by the server.
export type TagSuggestions = Record<string, TagSuggestion[]>;
export const tagsApi = {
tags: (): Promise<Tag[]> => req("/api/tags"),
tagSuggestions: (): Promise<TagSuggestions> => req("/api/tags/suggestions"),
createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> =>
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
updateTag: (id: number, patch: { name?: string; color?: string }) =>
+7
View File
@@ -87,4 +87,11 @@ describe("selectDateChip", () => {
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
});
it("clears a selected year on any date pick (they're mutually exclusive)", () => {
// preset, custom, and 'any' all drop the year — it's the same publish-date narrowing.
expect(selectDateChip("30", base({ year: 2024 })).filters.year).toBeUndefined();
expect(selectDateChip("custom", base({ year: 2024 })).filters.year).toBeUndefined();
expect(selectDateChip("any", base({ year: 2024 })).filters.year).toBeUndefined();
});
});
+17 -3
View File
@@ -32,18 +32,32 @@ export function selectDateChip(
key: string,
filters: FeedFilters,
): { filters: FeedFilters; customOpen: boolean } {
// A date pick and the year control are mutually exclusive publish-date narrowings, so any date
// selection clears the year (and vice versa, in YearFilter).
if (key === "custom") {
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true };
return { filters: { ...filters, maxAgeDays: undefined, year: undefined }, customOpen: true };
}
if (key === "any" || key === "") {
return {
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined },
filters: {
...filters,
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
year: undefined,
},
customOpen: false,
};
}
const days = Number(key);
return {
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined },
filters: {
...filters,
maxAgeDays: days,
dateFrom: undefined,
dateTo: undefined,
year: undefined,
},
customOpen: false,
};
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface PanelLayout {
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
// appended by normalizeLayout, so nothing silently disappears.
const PANEL_ORDER: Record<PanelId, string[]> = {
feed: ["date", "channel", "tags", "language", "topic"],
feed: ["date", "year", "channel", "tags", "language", "topic"],
plex: [
"playlists",
"watch",
+5
View File
@@ -14,6 +14,9 @@ export const qk = {
me: () => ["me"] as const,
myStatus: () => ["my-status"] as const,
tags: () => ["tags"] as const,
// Per-channel suggested user tags, learned from the manual-tag pattern (E8·S4). Separate root so
// accepting a suggestion can invalidate it without touching the whole tag catalog.
tagSuggestions: () => ["tag-suggestions"] as const,
// --- feed / video ---
// Every parameterized key guards on `!== undefined` (not truthiness), so "no arg → bare prefix
@@ -29,6 +32,8 @@ export const qk = {
filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
feedSummary: (filters?: FeedFilters) =>
filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const),
feedYears: (filters?: FeedFilters) =>
filters !== undefined ? (["feed-years", filters] as const) : (["feed-years"] as const),
ytSearch: (q?: string | null, count?: number) =>
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
videoDetail: (id: string) => ["video-detail", id] as const,
+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",
+17
View File
@@ -140,4 +140,21 @@ describe("filterParamUpdates", () => {
const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS);
expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
});
it("round-trips the year filter; emitted only when set", () => {
expect(filtersToParams({ ...DEFAULTS, year: 2024 }).get("year")).toBe("2024");
expect(filtersToParams({ ...DEFAULTS, year: undefined }).has("year")).toBe(false);
expect(paramsToFilters(new URLSearchParams("year=2024"), DEFAULTS).year).toBe(2024);
// Junk / absent → undefined (off), never a NaN year.
expect(paramsToFilters(new URLSearchParams("year=nope"), DEFAULTS).year).toBeUndefined();
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).year).toBeUndefined();
});
it("round-trips the 'No tag' (untagged) flag; emitted only when on", () => {
expect(filtersToParams({ ...DEFAULTS, untagged: true }).get("notag")).toBe("1");
expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false);
expect(paramsToFilters(new URLSearchParams("notag=1"), DEFAULTS).untagged).toBe(true);
// Absent → the base value is left untouched (undefined here, i.e. off).
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).untagged).toBeUndefined();
});
});
+6
View File
@@ -20,10 +20,12 @@ const KEYS = [
"tagmode_topic",
"tagmode_other",
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
"notag",
"channel",
"maxage",
"from",
"to",
"year",
"mindur",
"maxdur",
] as const;
@@ -44,10 +46,12 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
}
if (f.untagged) p.set("notag", "1");
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);
if (f.dateTo) p.set("to", f.dateTo);
if (f.year) p.set("year", String(f.year));
if (f.minDuration != null) p.set("mindur", String(f.minDuration));
if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration));
return p;
@@ -95,10 +99,12 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
other: params.get("tagmode_other") === "and" ? "and" : "or",
};
if (params.has("notag")) f.untagged = params.get("notag") === "1";
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
f.maxAgeDays = num(params.get("maxage"));
f.dateFrom = params.get("from") || undefined;
f.dateTo = params.get("to") || undefined;
f.year = num(params.get("year"));
f.minDuration = num(params.get("mindur"));
f.maxDuration = num(params.get("maxdur"));
return f;
+18
View File
@@ -15,6 +15,7 @@ export interface ActiveFilter {
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
untagged: false,
channelIds: [],
sort: "newest",
includeNormal: true,
@@ -66,6 +67,15 @@ export function useActiveFilters(
});
}
// "No tag" (item B): channels the user hasn't personally tagged. One removable chip.
if (filters.untagged) {
out.push({
key: "untagged",
label: t("feed.chips.untagged"),
remove: () => setFilters({ ...filters, untagged: false }),
});
}
if (filters.show !== "unwatched") {
out.push({
key: "show",
@@ -110,6 +120,14 @@ export function useActiveFilters(
});
}
if (filters.year) {
out.push({
key: "year",
label: String(filters.year),
remove: () => setFilters({ ...filters, year: undefined }),
});
}
if (filters.maxAgeDays || filters.dateFrom || filters.dateTo) {
const label = filters.maxAgeDays
? t("feed.chips.maxAge", { count: filters.maxAgeDays })