Skip to content

feat: per-type search filters on list pages (closes #25) - #34

Merged
mforce merged 2 commits into
mainfrom
feat/list-filters
May 13, 2026
Merged

mforce merged 2 commits into
mainfrom
feat/list-filters

Conversation

@mforce

@mforce mforce commented May 11, 2026 •

Copy link
Copy Markdown
Owner

Closes #25. Adds a faceted-filter layer on every list page, end to end. URL-synced so deep-linking + browser back/forward work, AND across distinct filters and OR within multi-value filters per the issue spec.

Summary

Backend

Each list endpoint gains the missing filter params (existing ones — format, year, platform, digital — stay for back-compat):

New params
GET /api/movies yearFrom, yearTo, director, studio, genre, status, watchStatus, ratingMin, tag (repeatable)
GET /api/music yearFrom, yearTo, artist, label, genre, status, ratingMin, tag
GET /api/games yearFrom, yearTo, publisher, developer, status, completionStatus, digitalStore, ratingMin, tag
  • Semantics: AND across distinct filters, OR within the multi-value tag filter (item matches if any of its tags are in the requested set). Per the issue: "OR semantics within multi-value filters."
  • Tag values are lower-cased before the predicate runs (matches TagResolver), so the URL doesn't have to care about case.
  • Predicates are explicit per type — three endpoints don't justify a generic builder.

Frontend

  • services/filters.ts — per-type Filters interfaces, filtersToParams() / paramsToFilters() round-trip via URLSearchParams, and a useFiltersState(type) hook that bidirectionally syncs filters with the URL ?search. Unrelated params (e.g. the search input's q if you wire one up) are preserved on every update.
  • services/collection.ts — useList(type, query, filters?) serializes filters into the request URL and folds them into the TanStack query key so a filter change refetches deterministically.
  • components/FiltersPanel.tsx — collapsible disclosure with an active-count + Clear all; opens to a grid of fields appropriate to the type (year range, free-text substring inputs, enum selects, digital/physical toggle, min-rating select). Active filters render as removable chips above the panel body so users can drop one at a time.
  • CollectionList wires the panel above the search input and threads the filters into useList.

Test plan

CI

  • dotnet test — server 286/286 (was 278; +8).
  • npm test -- --run — client 73/73 (was 66; +7).
  • Server + Vite builds clean.

Backend (per-type filter tests)

  • Movies: year range inclusive, director/studio/genre substring, status+watch+rating, tag OR semantics, AND combination (director=Nolan&yearFrom=2015).
  • Music: year range + artist/label/genre/status/rating.
  • Games: year range + publisher / developer / completionStatus / rating. Existing platform + digital tests stay green.

Frontend (filters.test.ts)

  • filtersToParams: omits undefined / null / empty / NaN; repeats array values; stringifies booleans + numbers; drops empty strings inside arrays.
  • activeFilterCount: counts set scalars, treats empty arrays as inactive, ignores empty strings.

Manual

  • Open /movies, click Filters, set Director: Nolan + Year range: 2015–. List narrows to a single row. URL shows ?director=Nolan&yearFrom=2015. Browser back undoes filters one step.
  • Refresh the page after deep-linking that URL: filters re-hydrate, list stays narrowed.
  • Click the chip for Director: Nolan ✕ → removed from URL + list reflows.
  • Clear all wipes every filter but leaves the search input's q alone.
  • Repeat the smoke on /music and /games.

Out of scope

  • Saved filter presets / "smart playlists" — listed in the issue, deferred.
  • Sort options — separate enhancement; default OrderByDescending(AddedAt) stays.
  • Server-side fuzzy / typo tolerance — LIKE %x% is enough at our cardinality.
  • Multi-value enum filters (e.g. ?platform=Ps5&platform=Switch) — single-value selects today; documented as a future enhancement if the URL parameter starts feeling cramped. The server already accepts repeated tag so the same shape will plug in.

🤖 Generated with Claude Code


Generated by Claude Code

claude and others added 2 commits May 11, 2026 08:40
Adds a faceted-filter layer on every list page, end to end. URL-synced
so deep-linking + browser back/forward work.

Backend
- GET /api/movies extended: yearFrom / yearTo (range), director,
  studio, genre (substring), status, watchStatus, ratingMin, tag
  (repeatable).
- GET /api/music: yearFrom / yearTo, artist, label, genre, status,
  ratingMin, tag.
- GET /api/games: yearFrom / yearTo, publisher, developer, status,
  completionStatus, digitalStore, ratingMin, tag. Pre-existing platform
  + digital + year stay.
- Cross-type semantics from the issue:
    * AND across distinct filters.
    * OR within the multi-value `tag` filter (item matches if any of
      its tags are in the requested set).
- Tag values are normalised lower-case to match TagResolver before the
  predicate runs, so the URL doesn't have to care about case.
- Each predicate is explicit per type (no generic builder) -- three
  endpoints aren't worth that abstraction yet.

Client
- services/filters.ts: per-type Filters interfaces (MovieFilters /
  AlbumFilters / GameFilters), filtersToParams() / paramsToFilters()
  round-trip via URLSearchParams, and useFiltersState() that
  bidirectionally syncs filters with the URL search-string via
  react-router. Free-text `q` and any other unrelated params are
  preserved on every update so the search input doesn't get clobbered.
- services/collection.ts: useList(type, query, filters?) serializes
  filters into the request URL and folds them into the TanStack
  queryKey so changes refetch deterministically.
- components/FiltersPanel.tsx: collapsible disclosure trigger with an
  "active" count + "Clear all"; opens to a grid of fields appropriate
  to the type (year range, free-text substring inputs, enum selects,
  digital/physical toggle, min-rating select, etc.). Active filters
  render as removable chips above the panel body so users can drop one
  at a time without opening the panel.
- CollectionList wires the panel above the search input and threads
  the filters into useList.

Tests
- Server (+9): movies year range / director-studio-genre /
  status+watch+rating / tag OR / AND combination; music year range +
  artist/label/genre/status/rating; games year range + publisher /
  developer / completionStatus / rating; existing tests stay green
  (we added flat new params with optional binders, no breaking
  changes).
- Client (+7): filtersToParams round-trip (omitting empty / NaN,
  repeating arrays, stringifying booleans/numbers, dropping empty
  array entries) plus activeFilterCount semantics.

Server 286/286 (was 278; +8). Client 73/73 (was 66; +7). Build clean.
does leave `q` alone

The PR description claimed "Clear all wipes every filter but leaves
the search input's q alone" -- but the search input was useState, not
URL-synced, so the URL never had a `q` to begin with. The claim was
vacuously true and confusing in practice ("I cleared and my search
went too").

Move the search input to ?q= via useSearchParams. useFiltersState
already preserves every param it doesn't own, so the two states are
genuinely independent now: Clear-all drops only the filter params,
the search input stays put across filter changes, and the whole list
state (query + filters) deep-links from a single URL.
@mforce
mforce merged commit e0d9bb7 into main May 13, 2026
1 check passed
@mforce
mforce deleted the feat/list-filters branch May 13, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Search / filter list pages by per-type attributes

2 participants