feat(feed): filter by year — per-year count facet + year control (E8·S2)
Add a Year filter to the feed rail: quick chips for recent years plus a 'More…' popover listing all years with per-year video counts, sortable by year or count. Selecting a year narrows the feed to that whole calendar year (a published_after/before range applied in the api layer) and is mutually exclusive with the Upload-date control. Backend: new /api/feed/years endpoint + feed_years() — one grouped query that honours every filter EXCEPT the publish-date narrowing (dropped), so the year list stays a live drill-down and switching years never zeroes the others. FE keys the list on the date-stripped filters, so picking a year re-queries the feed but not the list. Wires year through FeedFilters, the filterParams/URL (?year) round-trip, the active-filter chip row, and the panel layout. i18n HU+EN. Tests: feed_years count query (date narrowing ignored, other filters honoured); dateFilter year-clearing; urlState year round-trip.
This commit is contained in:
@@ -437,6 +437,26 @@ 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()
|
||||
year = func.extract("year", 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
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.feedquery import (
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
feed_summary,
|
||||
feed_years,
|
||||
filtered_query,
|
||||
keyset_predicate,
|
||||
order_by,
|
||||
@@ -206,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
|
||||
|
||||
|
||||
@@ -201,6 +201,58 @@ def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user
|
||||
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},
|
||||
]
|
||||
|
||||
|
||||
@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,
|
||||
|
||||
Reference in New Issue
Block a user