Skip to content

fix(filters): project filters — OR/AND tags + filtered facets + stage row#127

Merged
themightychris merged 3 commits into
mainfrom
fix/project-filters
Jun 2, 2026
Merged

fix(filters): project filters — OR/AND tags + filtered facets + stage row#127
themightychris merged 3 commits into
mainfrom
fix/project-filters

Conversation

@themightychris
Copy link
Copy Markdown
Member

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

Before After
Tag filter: AND across repeats OR within namespace, AND across namespaces — matches conventional faceted search; widens within a group, narrows across groups
Facet counts: "unfiltered corpus" Filtered with self-namespace exclusion — counts answer "how many results if I also pick X"; selected tags pinned even when count=0
Stage facet: tab inside the sidebar Horizontal pill row above the search box — stages are a 7-value fixed enum; better hierarchy

Files of note

  • API: `apps/api/src/services/project.ts` (predicate factory + 5-pass filtering) + `apps/api/src/store/memory/facets.ts` (per-request `computeProjectFacets`, replaces global cache).
  • SPA: `apps/web/src/lib/api.ts` (`FacetEntry` rewritten), `apps/web/src/components/FacetSidebar.tsx` (reads `e.tag`, sorts active first, drops stage tab), `apps/web/src/components/StageFilterRow.tsx` (new horizontal pill row), `apps/web/src/screens/ProjectsIndex.tsx` (hoists the row).
  • Tests: `apps/api/tests/project-filters.test.ts` (9 cases driving `ProjectService` directly — OR-within, AND-across, self-exclusion, pinning) + `apps/web/tests/ProjectsIndex.test.tsx` (4 new SPA cases — chip rendering, click → URL, stage pill, toggle-twice; any of these would have caught the original bug).
  • Specs: specs/api/projects.md + specs/screens/projects-index.md.
  • Plan: plans/project-filters-fix.md.

Test plan

  • `npm run type-check && npm run lint` clean
  • `npm run -w apps/api test` — 397/397 (was 387; +9 project-filters, +1 updated read-api)
  • `npm run -w apps/web test` — 73/73 (was 68; +4 new ProjectsIndex cases)
  • `npm run -w packages/shared test` — 75/75
  • After deploy: click chips in each tab, confirm correct toggle / URL / list narrowing; click stage pill, confirm narrows; clear all works

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

themightychris and others added 3 commits June 2, 2026 11:32
…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>
@themightychris themightychris merged commit 63854e3 into main Jun 2, 2026
1 check passed
@themightychris themightychris deleted the fix/project-filters branch June 2, 2026 16:30
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.

1 participant