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}
|
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 ---------------------------------------------
|
# --- Keyset (cursor) pagination ---------------------------------------------
|
||||||
#
|
#
|
||||||
# Each sort is described as an ordered list of key columns (expression + direction
|
# Each sort is described as an ordered list of key columns (expression + direction
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.feedquery import (
|
|||||||
decode_cursor,
|
decode_cursor,
|
||||||
encode_cursor,
|
encode_cursor,
|
||||||
feed_summary,
|
feed_summary,
|
||||||
|
feed_years,
|
||||||
filtered_query,
|
filtered_query,
|
||||||
keyset_predicate,
|
keyset_predicate,
|
||||||
order_by,
|
order_by,
|
||||||
@@ -206,6 +207,19 @@ def get_feed_summary(
|
|||||||
return feed_summary(db, user, **params)
|
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):
|
class VideoStateIn(BaseModel):
|
||||||
status: str
|
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"}
|
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)
|
@pytest.mark.parametrize("case", _SUMMARY_CASES)
|
||||||
def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case):
|
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,
|
# /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces,
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export default function DateFilterSelect({
|
|||||||
...filters,
|
...filters,
|
||||||
dateFrom: e.target.value || undefined,
|
dateFrom: e.target.value || undefined,
|
||||||
maxAgeDays: undefined,
|
maxAgeDays: undefined,
|
||||||
|
year: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={dateInputCls}
|
className={dateInputCls}
|
||||||
@@ -169,6 +170,7 @@ export default function DateFilterSelect({
|
|||||||
...filters,
|
...filters,
|
||||||
dateTo: e.target.value || undefined,
|
dateTo: e.target.value || undefined,
|
||||||
maxAgeDays: undefined,
|
maxAgeDays: undefined,
|
||||||
|
year: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={dateInputCls}
|
className={dateInputCls}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { type PanelItem } from "./PanelGroups";
|
|||||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
import DateFilterSelect from "./DateFilterSelect";
|
import DateFilterSelect from "./DateFilterSelect";
|
||||||
|
import YearFilter from "./YearFilter";
|
||||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
import { useChannelsActions } from "./ChannelsProvider";
|
import { useChannelsActions } from "./ChannelsProvider";
|
||||||
@@ -145,6 +146,7 @@ export default function Sidebar({
|
|||||||
|
|
||||||
const available: Record<string, boolean> = {
|
const available: Record<string, boolean> = {
|
||||||
date: true,
|
date: true,
|
||||||
|
year: true,
|
||||||
channel: true,
|
channel: true,
|
||||||
language: languages.length > 0,
|
language: languages.length > 0,
|
||||||
topic: topics.length > 0,
|
topic: topics.length > 0,
|
||||||
@@ -159,6 +161,8 @@ export default function Sidebar({
|
|||||||
return (
|
return (
|
||||||
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
|
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
|
||||||
);
|
);
|
||||||
|
case "year":
|
||||||
|
return <YearFilter filters={filters} setFilters={setFilters} />;
|
||||||
case "language":
|
case "language":
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
const facetFilters: FeedFilters = {
|
||||||
|
...filters,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,16 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"topic": "Topic",
|
"topic": "Topic",
|
||||||
"tags": "Your tags",
|
"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": {
|
"show": {
|
||||||
"unwatched": "Unwatched",
|
"unwatched": "Unwatched",
|
||||||
|
|||||||
@@ -38,7 +38,16 @@
|
|||||||
"language": "Nyelv",
|
"language": "Nyelv",
|
||||||
"topic": "Téma",
|
"topic": "Téma",
|
||||||
"tags": "Saját címkék",
|
"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": {
|
"show": {
|
||||||
"unwatched": "Nem nézett",
|
"unwatched": "Nem nézett",
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ export interface FeedFilters {
|
|||||||
maxDuration?: number;
|
maxDuration?: number;
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: 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
|
// The filter half of the query (everything except paging/sort), shared by the feed and
|
||||||
@@ -100,9 +110,16 @@ function filterParams(f: FeedFilters): URLSearchParams {
|
|||||||
p.set("include_live", String(f.includeLive));
|
p.set("include_live", String(f.includeLive));
|
||||||
p.set("show", f.show);
|
p.set("show", f.show);
|
||||||
f.channelIds.forEach((id) => p.append("channel_ids", id));
|
f.channelIds.forEach((id) => p.append("channel_ids", id));
|
||||||
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
// Year (item 8) is a whole-year publish-date range and wins over the maxAge/custom-range controls
|
||||||
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
// (they're mutually exclusive publish-date narrowings — the setters enforce it, this guards the wire).
|
||||||
if (f.dateTo) p.set("published_before", f.dateTo);
|
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.minDuration != null) p.set("min_duration", String(f.minDuration));
|
||||||
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
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.
|
// Only relevant in Library scope; the backend ignores it for "my". Default = hide search.
|
||||||
@@ -148,6 +165,10 @@ export const feedApi = {
|
|||||||
// the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13).
|
// 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> }> =>
|
feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record<string, number> }> =>
|
||||||
req(`/api/feed/summary?${filterParams(f).toString()}`),
|
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
|
// 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()).
|
// recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
|
||||||
setState: (id: string, status: string) =>
|
setState: (id: string, status: string) =>
|
||||||
|
|||||||
@@ -87,4 +87,11 @@ describe("selectDateChip", () => {
|
|||||||
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
|
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
|
||||||
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,18 +32,26 @@ export function selectDateChip(
|
|||||||
key: string,
|
key: string,
|
||||||
filters: FeedFilters,
|
filters: FeedFilters,
|
||||||
): { filters: FeedFilters; customOpen: boolean } {
|
): { 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") {
|
if (key === "custom") {
|
||||||
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true };
|
return { filters: { ...filters, maxAgeDays: undefined, year: undefined }, customOpen: true };
|
||||||
}
|
}
|
||||||
if (key === "any" || key === "") {
|
if (key === "any" || key === "") {
|
||||||
return {
|
return {
|
||||||
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined },
|
filters: {
|
||||||
|
...filters,
|
||||||
|
maxAgeDays: undefined,
|
||||||
|
dateFrom: undefined,
|
||||||
|
dateTo: undefined,
|
||||||
|
year: undefined,
|
||||||
|
},
|
||||||
customOpen: false,
|
customOpen: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const days = Number(key);
|
const days = Number(key);
|
||||||
return {
|
return {
|
||||||
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined },
|
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined, year: undefined },
|
||||||
customOpen: false,
|
customOpen: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// appended by normalizeLayout, so nothing silently disappears.
|
||||||
const PANEL_ORDER: Record<PanelId, string[]> = {
|
const PANEL_ORDER: Record<PanelId, string[]> = {
|
||||||
feed: ["date", "channel", "tags", "language", "topic"],
|
feed: ["date", "year", "channel", "tags", "language", "topic"],
|
||||||
plex: [
|
plex: [
|
||||||
"playlists",
|
"playlists",
|
||||||
"watch",
|
"watch",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const qk = {
|
|||||||
filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
|
filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
|
||||||
feedSummary: (filters?: FeedFilters) =>
|
feedSummary: (filters?: FeedFilters) =>
|
||||||
filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const),
|
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) =>
|
ytSearch: (q?: string | null, count?: number) =>
|
||||||
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
||||||
videoDetail: (id: string) => ["video-detail", id] as const,
|
videoDetail: (id: string) => ["video-detail", id] as const,
|
||||||
|
|||||||
@@ -141,6 +141,15 @@ describe("filterParamUpdates", () => {
|
|||||||
expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
|
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", () => {
|
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: true }).get("notag")).toBe("1");
|
||||||
expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false);
|
expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const KEYS = [
|
|||||||
"maxage",
|
"maxage",
|
||||||
"from",
|
"from",
|
||||||
"to",
|
"to",
|
||||||
|
"year",
|
||||||
"mindur",
|
"mindur",
|
||||||
"maxdur",
|
"maxdur",
|
||||||
] as const;
|
] as const;
|
||||||
@@ -50,6 +51,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
|||||||
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
|
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
|
||||||
if (f.dateFrom) p.set("from", f.dateFrom);
|
if (f.dateFrom) p.set("from", f.dateFrom);
|
||||||
if (f.dateTo) p.set("to", f.dateTo);
|
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.minDuration != null) p.set("mindur", String(f.minDuration));
|
||||||
if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration));
|
if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration));
|
||||||
return p;
|
return p;
|
||||||
@@ -102,6 +104,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
|
|||||||
f.maxAgeDays = num(params.get("maxage"));
|
f.maxAgeDays = num(params.get("maxage"));
|
||||||
f.dateFrom = params.get("from") || undefined;
|
f.dateFrom = params.get("from") || undefined;
|
||||||
f.dateTo = params.get("to") || undefined;
|
f.dateTo = params.get("to") || undefined;
|
||||||
|
f.year = num(params.get("year"));
|
||||||
f.minDuration = num(params.get("mindur"));
|
f.minDuration = num(params.get("mindur"));
|
||||||
f.maxDuration = num(params.get("maxdur"));
|
f.maxDuration = num(params.get("maxdur"));
|
||||||
return f;
|
return f;
|
||||||
|
|||||||
@@ -120,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) {
|
if (filters.maxAgeDays || filters.dateFrom || filters.dateTo) {
|
||||||
const label = filters.maxAgeDays
|
const label = filters.maxAgeDays
|
||||||
? t("feed.chips.maxAge", { count: filters.maxAgeDays })
|
? t("feed.chips.maxAge", { count: filters.maxAgeDays })
|
||||||
|
|||||||
Reference in New Issue
Block a user