fix(filters): project filters — OR/AND tags + filtered facets + stage row#127
Merged
Conversation
…facets + stage row
User report: clicking any filter chip "selected them all" and toggling
any after that flipped the whole tab on/off without changing results.
Root cause: the SPA's FacetEntry type expected `{ handle, slug, … }` but
the API has always emitted `{ tag, title, count }` per the spec. With
neither `handle` nor `slug` on the wire, FacetSidebar's fallback
produced `handle = "topic."` for every chip in the Topic tab (and
similarly per other namespaces). Every chip shared the same toggle
key → clicking any flipped the whole tab.
Pre-existing bug since the SPA's first feature drop (2f0a62c / 427c2bf),
not a recent regression. No test exercised the click → URL → fetch
path against real-shaped data so it stayed dormant.
Bug fix piggybacks three UX upgrades worth the same review pass:
**Tag filter semantics**: OR within namespace, AND across namespaces.
Spec previously said "AND across repeats" — the new pattern matches
the conventional faceted-search behavior (within a facet group users
widen; across groups they narrow). `?tag=topic.transit&tag=topic.mapping`
now returns projects tagged with either, and adding `tech.python` narrows
to that AND (transit OR mapping).
**Facet counts**: filtered with self-namespace exclusion (replaces
"unfiltered corpus"). Each tag-namespace facet is counted over the
project set filtered by every active criterion except tag filters in
that same namespace — so the user can widen their selection in a
namespace without losing track. Selected tags get pinned with count 0
when no other project in the filtered set carries them.
**Stage facet hoisted out of the sidebar** into a horizontal pill row
above the search box. Stages are a 7-value fixed enum and benefit from
being visible at a glance; tabs hide them behind a click. New
StageFilterRow component; sidebar tabs reduce to Topics/Tech/Events.
API: ProjectService.list builds a predicate factory with optional
exclusions and runs 5 filter passes (main listing + 4 facet self-
exclusions). Per-request, no caching — at civic scale this is ~1-2ms.
The previously-cached `getProjectFacets` is replaced by per-request
`computeProjectFacets`; `getPeopleFacets` left as-is (out of scope).
Tests: 9 new API tests (project-filters.test.ts) drive ProjectService
directly. 4 new SPA tests (ProjectsIndex.test.tsx) cover the click →
URL → fetch path that would have caught the original bug. read-api's
"facets reflect unfiltered corpus" assertion reframed for the new
contract. Full sweep: api 397/397, web 73/73, shared 75/75. Type-check
+ lint clean.
Spec changes: specs/api/projects.md (tag + facet semantics rewritten),
specs/screens/projects-index.md (stage-row section above results;
sidebar tabs reduced to Topics/Tech/Events).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan file for the filter regression fix landed in the prior commit. Will flip to done with a closeout commit once the PR opens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All validation items ticked, PR opened. Status: in-progress → done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the project-filter regression you reported. Bug fix piggybacks three UX upgrades that needed the same surface review pass.
Bug (long-standing, dormant since the first feature drop)
The SPA's `FacetEntry` type expected `{ handle, slug, title, count }`; the API has always emitted `{ tag, title, count }` per the spec. Neither `handle` nor `slug` was ever on the wire. `FacetSidebar`'s fallback collapsed every chip in a tab to the same handle (`topic.`, `tech.`, `event.`) → every click toggled the whole tab, and the resulting URL (`?tag=topic.`) matched no projects.
Pre-existing since the SPA's first feature drop (2f0a62c / 427c2bf), not a recent regression. No test exercised the click → URL → fetch path against real-shaped data.
UX upgrades shipped in the same PR
Files of note
Test plan
Behavior change for external API consumers
`/api/projects?tag=a&tag=b` semantics change. Anyone hitting the API directly who relied on strict-AND-across-repeats would see different results. In practice the only consumer is our own SPA, which was already broken on this path.
🤖 Generated with Claude Code