From 1dff80926828a2d90f8e20e0f4ffbb2b74a1ff5a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 15:08:50 +0100 Subject: [PATCH] feat(filters): pick one year from a decade grid, in the feed and in Plex A shared YearPicker: the recent years as chips with their counts, and a More dialog that lays every year out by decade (sortable by count), so a ninety-year library fits on one screen. The feed's scrolling popover gives way to it; the Plex rail gets it above its range fields, a pick setting both ends. The Plex facets now count titles per year, the year filter left out. --- backend/app/plex/library.py | 25 +-- backend/tests/test_plex_service.py | 22 +++ frontend/src/components/ui/README.md | 14 +- .../src/components/ui/YearPicker.dom.test.tsx | 83 +++++++++ frontend/src/components/ui/YearPicker.tsx | 160 ++++++++++++++++++ frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + frontend/src/lib/api/plex.ts | 2 + frontend/src/modules/feed/YearFilter.tsx | 151 +++-------------- frontend/src/modules/plex/PlexSidebar.tsx | 40 +++-- 10 files changed, 338 insertions(+), 161 deletions(-) create mode 100644 frontend/src/components/ui/YearPicker.dom.test.tsx create mode 100644 frontend/src/components/ui/YearPicker.tsx diff --git a/backend/app/plex/library.py b/backend/app/plex/library.py index b767535a..a78d17fd 100644 --- a/backend/app/plex/library.py +++ b/backend/app/plex/library.py @@ -242,6 +242,7 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr "content_ratings": [], "studios": [], "playable": [], + "years": [], "year_min": None, "year_max": None, "rating_max": None, @@ -302,7 +303,7 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr cr_counts: dict[str, int] = {} studio_counts: dict[str, int] = {} playable_counts: dict[str, int] = {} - years: list[int] = [] + year_counts: dict[int, int] = {} ratings: list[float] = [] durs: list[int] = [] @@ -383,21 +384,22 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr ) for cls, c in db.execute(pcq).all(): playable_counts[cls] = playable_counts.get(cls, 0) + c - # Year bounds — exclude the year filter. - by = db.execute( + # Titles per year — exclude the year filter, so a picked year never empties the list it was + # picked from. The year bounds are read from the same counts. + yq = ( wsq( - select(func.min(model.year), func.max(model.year)), + select(model.year, func.count()), model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True, ) - ).one() - if by[0] is not None: - years.append(by[0]) - if by[1] is not None: - years.append(by[1]) + .where(model.year.isnot(None)) + .group_by(model.year) + ) + for y, c in db.execute(yq).all(): + year_counts[y] = year_counts.get(y, 0) + c # Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the # rating filter and the UI badge use (IMDb preferred, audience fallback). br = db.scalar( @@ -465,8 +467,9 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr for c in PLAYBACK_CLASSES if c in playable_counts ], - "year_min": min(years) if years else None, - "year_max": max(years) if years else None, + "years": [{"year": y, "count": c} for y, c in sorted(year_counts.items(), reverse=True)], + "year_min": min(year_counts) if year_counts else None, + "year_max": max(year_counts) if year_counts else None, "rating_max": max(ratings) if ratings else None, "duration_min": min(durs) if durs else None, "duration_max": max(durs) if durs else None, diff --git a/backend/tests/test_plex_service.py b/backend/tests/test_plex_service.py index 181e0cc3..1922a60c 100644 --- a/backend/tests/test_plex_service.py +++ b/backend/tests/test_plex_service.py @@ -105,6 +105,28 @@ def test_the_facets_count_what_the_caller_would_see(db, make_user): assert (theirs["year_min"], mine["year_min"]) == (2001, 1990) +def test_the_facets_count_titles_per_year_without_the_year_filter(db, make_user): + # The year picker lists every year with its count. Like each facet it drops its own filter (a + # picked year must not empty the list it was picked from) and keeps the others; movies and shows + # of one year add up, newest first. + me = make_user() + movies, shows = _lib(db), _lib(db, "2", kind="show") + _movie(db, movies, "a", genres=["Drama"], year=2001) + _movie(db, movies, "b", genres=["Drama"], year=2001) + _movie(db, movies, "c", genres=["Drama"], year=1990) + _movie(db, movies, "d", genres=["Horror"], year=1985) + _movie(db, movies, "e", genres=["Drama"]) # no year: not a year to pick + db.add(PlexShow(rating_key="s1", library_id=shows.id, title="S", genres=["Drama"], year=1990)) + db.commit() + + f = LibraryFilters(genres="Drama", year_min=2001, year_max=2001) + got = library.facets(db, me, scope="both", show="all", lang="en", f=f) + assert got["years"] == [{"year": 2001, "count": 2}, {"year": 1990, "count": 2}] + assert (got["year_min"], got["year_max"]) == (1990, 2001) + movie_only = library.facets(db, me, scope="movie", show="all", lang="en", f=f) + assert movie_only["years"] == [{"year": 2001, "count": 2}, {"year": 1990, "count": 1}] + + def test_an_episode_search_joins_the_callers_state(db, make_user): me, other = make_user(), make_user() lib = _lib(db, "2", kind="show") diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index a39e3893..fc96ed83 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage ## What lives here -| area | components | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) | -| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw ` - ))} - - -
- {sorted.map((y) => { - const on = selected === y.year; - return ( - { - pick(on ? undefined : y.year); - setOpen(false); - }} - label={String(y.year)} - className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${ - on ? "text-fg" : "text-muted" - }`} - > - - {y.year} - {y.count} - - ); - })} -
- - - )} - + + setFilters({ + ...filters, + year, + maxAgeDays: undefined, + dateFrom: undefined, + dateTo: undefined, + }) + } + /> ); } diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index 80ead6cd..12737e4e 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -21,6 +21,7 @@ import { usePlexSavedViews } from "./plexSavedViews"; import { usePlex, usePlexActions } from "./plexContext"; import { DismissibleChip } from "../../components/ui/Chip"; import { Input } from "../../components/ui/Input"; +import YearPicker from "../../components/ui/YearPicker"; import { useRail } from "../../lib/useRail"; import { msg } from "../../lib/notifications"; @@ -173,18 +174,33 @@ export default function PlexSidebar() { id: "year", title: t("plex.filter.year"), body: ( -
- patch({ yearMin: v })} - /> - – - patch({ yearMax: v })} - /> +
+ {/* One year is a range of one: picking it sets both ends, as the year on an info page does, + so the range fields below show it and a decade stays typeable there. */} + {facets && facets.years.length > 0 && ( + patch({ yearMin: year ?? null, yearMax: year ?? null })} + /> + )} +
+ patch({ yearMin: v })} + /> + – + patch({ yearMax: v })} + /> +
), },