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:
2026-08-25 02:55:12 +02:00
parent 2c843e549b
commit edcea87ea5
16 changed files with 373 additions and 9 deletions
+24 -3
View File
@@ -81,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
@@ -100,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.
@@ -148,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) =>
+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();
});
});
+11 -3
View File
@@ -32,18 +32,26 @@ 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",
+2
View File
@@ -29,6 +29,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,
+9
View File
@@ -141,6 +141,15 @@ describe("filterParamUpdates", () => {
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);
+3
View File
@@ -25,6 +25,7 @@ const KEYS = [
"maxage",
"from",
"to",
"year",
"mindur",
"maxdur",
] as const;
@@ -50,6 +51,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
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;
@@ -102,6 +104,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
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;
+8
View File
@@ -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) {
const label = filters.maxAgeDays
? t("feed.chips.maxAge", { count: filters.maxAgeDays })