---
title: Rebuild the /news search and filter
type: task
status: completed
description: >-
  Port /news onto the data-views select-with-search control, merge the team/division/conference
  controls into one, and widen the vocabulary with dynamic tags, a time range, and
  per-source-instance sources.
base_uri: user:task/league/rebuild-news-search-and-filter.md
created_at: '2026-09-16T21:34:07.080Z'
entity_id: 6b92a5e9-240f-44ee-a216-a76ba2a350e1
finished_at: '2026-09-16T23:53:19.582Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-16T23:53:19.582Z'
visibility_analyzed_at: '2026-11-05T23:49:54.337Z'
---

## Objective

Rebuild `/news` search and filter on the data-views select-with-search control, and widen the filter vocabulary: per-source-instance sources, one merged team control, a dynamic tag filter, and a time range.

## Remaining

Nothing. Shipped and verified in production 2026-09-16.

- base activated on both hosts at `cf215e739`; `GET /api/content-feed/{tags,sources}` serve 200 and the five open `code-freshness` signals cleared.
- league deployed at `804bf412e` on both hosts, bundle included, carrying a 47-commit range.
- Production `/news` now serves the real vocabulary: 10 content tags and 222 sources across 6 kinds. The four never-carried tags are absent from the vocabulary and naming one is still a 400.
- react-table pin split resolved as leave-them; verified a clean lag, not a fork.

## Assumptions

- The reader-facing vocabulary must survive new tags being added upstream without a league deploy, so any tag list is fetched rather than committed. Stated because the cheap alternative — a constant in `news-head.js` alongside `SOURCE_TYPE_VALUES` — reads as correct and silently freezes the vocabulary.
- `/news` stays on the prose tier. The data-views control is app chrome, so it is reused and re-skinned in `news-head.styl`, the same treatment `@components/filter` already gets there.
- The route's `divisions` and `conferences` query parameters stay. They are a documented public enum with consumers league does not own; only the CLIENT stops sending them.

## Bearings

- Page: `app/views/pages/news/news.js`, `app/views/components/news-head/news-head.js`, `news-head.styl`
- Slice: `app/core/news/` — `reducer.js`, `sagas.js`, `actions.js`, `build-news-request-params.mjs`
- Route: `api/routes/news.mjs`, `libs-server/content-feed-client.mjs`
- Upstream: `extension/content-feed-query/lib/` — `normalize-feed-query.mjs`, `build-feed-query.mjs`, `build-feed-item-projection.mjs`, `content-feed-query-route.mjs`
- Control: `react-table/src/select-picker-panel/`, `react-table/src/filter-base/`. Deep-imported, no package entry export; `column-param-nfl-week-selector.js:7` is the existing precedent. Stylesheets ride along with the component import.

## Plan

### The control and the merged team filter

Self-contained in league's client. No API change.

- [x] Replace each `@components/filter` in `news-head.js` with `FilterBase` + `SelectPickerPanel`. Verify: every existing filter still narrows the stream, and the panel searches.
- [x] Merge `TEAM`, `DIVISION` and `CONFERENCE` into one `TEAM` control, `value_groups` = `nfl_team_value_groups`, items = `build_nfl_team_values()`. Verify: the two-pane conference-to-division tree scopes the team list, and `All` under a scoped node selects that node's teams only.
- [x] `SelectPickerPanel` keys on `id`, `build_nfl_team_values()` emits `value` — map one to the other in `with_selection`. Verify: a team click registers rather than silently no-opping.
- [x] Drop `divisions` and `conferences` from the slice, the sagas' `read_filters`, the page props and `count_active_news_filters`. Verify: `grep -rn "conferences" app/` returns nothing in the news tier.
- [x] Re-skin the panel to the prose palette in `news-head.styl`. Verify: no MUI blue, no card shadow, 17px form-control floor held. Operator reviewed the rendered page and accepted the skin.
- [x] Unify the row on one shared hairline: every control is a mono label over a value, the search box included — its border is gone and the rule under it is the field. Verify: the search box and the filters terminate on the same edge and take the accent together.
- [x] `FilterBase` needed a `class_name` seam first: its popper portals to `document.body`, so a rule anchored on `.news__head` reaches nothing and the bare react-table class would repaint every data-views page. Landed in react-table at `04b13b30`, league's pin bumped to match.

### Time range

- [x] Add a closed-set `time_range` to `api/routes/news.mjs`, resolved to `published_after` on the hour the way `resolve_published_after` already does. Verify: the cache key space stays bounded — two loads a minute apart hit one key.
- [x] Reconcile against the band horizon. Both write `published_after`; the more restrictive wins. Verify: a 24h range under the three-day Top band returns 24h of rows, not three days.
- [x] Wire the control and the slice field. Verify: the count in the head includes it.
- [x] Add a CUSTOM window as calendar dates, since the cache argument that refuses a caller-supplied `published_after` does not reach an absolute date. Verify: the end day is whole, an inverted pair is refused rather than reordered, and a non-existent date such as `2026-02-30` is caught rather than rolled into March.

### Tags

- [x] Add `GET /api/content-feed/tags` to `extension/content-feed-query`, reading the tag entities' `classifier_availability` rather than observed item rows. Verify: both axes return real tags; the list is exactly what the classifier may emit, so a new tag needs nothing classified with it first.
- [x] Proxy and cache it in league at `/news/tags`, and forward `tag_uris`. Verify: the proxy asks for the `content_type` axis and drops the tags the stream route refuses.
- [x] Reconcile with `include_content_types`. Superseded below: the two controls became one.

### TAG absorbs INCLUDE (operator scope change, 2026-09-16)

`INCLUDE` was the one control on the row whose polarity was reversed, and it sat beside a TAG control asking the same question the other way. TAG now shows the DEFAULT-ENABLED set instead, so the reader sees which tags are on — humor and meme among them, switched off — and toggles from there.

- [x] The selection IS the enabled set, so the exclusion is its complement. The client holds the withheld set (`exclude_tag_uris`, seeded with the page default) and the route takes it as authoritative for the movable tier, unioning `ALWAYS_EXCLUDED_CONTENT_TAG_URIS` in regardless. Verify: a caller can only ever exclude MORE, never less.
- [x] The complement is computed CLIENT-side. Computing it on the route would mean fetching the tag vocabulary on the hot stream path, and an upstream outage would then silently change filter semantics.
- [x] The untouched default is normalized back to an absent parameter, and an EMPTY set is emitted rather than dropped. Both halves are backwards from every other filter and both are load-bearing: the first keeps every default page load on one cache key, the second is the only way to say "I enabled every tag".
- [x] `include_content_types` and `tag_uris` stay on the route for consumers this page does not own, the same treatment `divisions` and `conferences` get. The client sends neither.
- [x] The control still disappears when the vocabulary is empty, so the degraded state is an absent control rather than one showing every tag off.

**One behaviour was traded away deliberately.** TAG no longer narrows to the selected tags — excluding the complement is not the same as requiring one of the selection, so an item carrying no content tag matches no exclusion and stays on the page. A reader who leaves only `Breaking` on gets breaking news plus whatever the classifier has not labelled. That is the right shape for a "what do I not want to see" control, and search answers the other question.

### Per-source-instance sources

Unblocked 2026-09-16: the operator chose the runtime vocabulary served to everyone. Nothing is committed to the public league repo, which is what the standing ruling actually forbids.

- [x] Add a `source_ids` filter to the query API, as a direct `source_id = ANY(...)` rather than a semi-join. Verify: it narrows WITHIN the `publicly_displayable` and `nfl_news_eligible` predicates rather than substituting for either, so naming ids is not a way around the licensing gate.
- [x] Put `source_id` in the `public` projection, on the same argument that put `item_id` there. `source_url` stays operator-only — the id is opaque, the url is the publisher's own address.
- [x] Add `GET /api/content-feed/sources`, gated on displayable + eligible + enabled, projecting id, title, type and the native handle read through the registry's `config_key`. Verify: the rest of `fetch_config` never leaves.
- [x] Proxy it in league at `/news/sources` and render SOURCE as a two-pane tree grouped by kind. Verify: the control falls back to the six kinds when the roster did not load, since the route degrades an outage to an empty list.
