Skip to content

Commit a1fe80c

Browse files
chore(plans): open project-filters-fix (in-progress)
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>
1 parent ef50267 commit a1fe80c

1 file changed

Lines changed: 188 additions & 0 deletions

File tree

plans/project-filters-fix.md

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
---
2+
status: in-progress
3+
depends: []
4+
specs:
5+
- specs/api/projects.md
6+
- specs/screens/projects-index.md
7+
issues: []
8+
pr:
9+
---
10+
11+
# Plan: project filters — fix the data-shape bug, switch to OR-within / AND-across, hoist stage to a row
12+
13+
## Scope
14+
15+
User reported that clicking any project filter chip "selects them all" and
16+
toggles the whole tab on/off without changing the result list. Investigation
17+
surfaced a long-standing data-shape bug (since the SPA's first feature drop)
18+
**plus** a few real UX shortcomings worth addressing in the same pass.
19+
20+
What ships:
21+
22+
- **API bug → API contract change.** The SPA's `FacetEntry` type expected
23+
`{ handle, slug, title, count }`; the API has always returned
24+
`{ tag, title, count }`. Both ends were misaligned with each other and
25+
with the spec. Align the SPA to the spec.
26+
- **Tag filter semantics: OR within namespace, AND across.** Spec previously
27+
said "AND across repeats" — the more common faceted-search pattern of
28+
OR-within / AND-across produces friendlier discovery.
29+
- **Facet counts: filtered with self-namespace exclusion.** Spec previously
30+
said "unfiltered corpus so counts don't whipsaw." Replaced with the
31+
filtered-with-self-exclusion pattern so counts honestly answer "how many
32+
results would I get if I also picked X." Selected tags get pinned into
33+
their namespace facet so the SPA can render them even when they fall
34+
below the top-10 cut.
35+
- **Stage facet hoisted to a horizontal pill row above results.** Stages
36+
are a 7-value fixed enum; the tabbed sidebar treatment hid them behind
37+
a click. New `StageFilterRow` component sits above the search box.
38+
39+
## Implements
40+
41+
- [api/projects.md](../specs/api/projects.md) — tag filter semantics + facet
42+
count semantics rewritten.
43+
- [screens/projects-index.md](../specs/screens/projects-index.md) — sidebar
44+
loses the Stages tab; new "Stage row" section documents the hoist.
45+
46+
## Approach
47+
48+
### 1. Spec changes (specops, source of truth first)
49+
50+
- `specs/api/projects.md` → tag is "OR within namespace, AND across namespaces"
51+
with worked example; facets are filtered-with-self-namespace-exclusion
52+
with pinned-selected behavior documented.
53+
- `specs/screens/projects-index.md` → new "Stage row" section above results;
54+
sidebar tabs reduced to Topics / Tech / Events.
55+
56+
### 2. API: `apps/api/src/services/project.ts`
57+
58+
Refactored the `list()` filter pass into a predicate factory that takes
59+
optional exclusions (`excludeStage`, `excludeTagNs`). The main listing
60+
uses the full predicate; each facet group is computed over a project
61+
set built with the right exclusion. Tag handles are grouped by namespace
62+
at request entry; the per-project tag-set check is OR within each
63+
namespace's filter set and AND across namespaces.
64+
65+
### 3. Facets: `apps/api/src/store/memory/facets.ts`
66+
67+
`getProjectFacets` (cached, unfiltered) → `computeProjectFacets` (per
68+
request, takes project sets per namespace exclusion). Pins active
69+
selections with `count = 0` when no other project in the set carries
70+
that tag, so the SPA always renders selected chips. `getPeopleFacets`
71+
is unchanged (still cached / unfiltered) — out of scope for this fix;
72+
people-list UX gets the same treatment when someone notices it
73+
matters.
74+
75+
### 4. SPA: data-shape fix + stage row
76+
77+
- `apps/web/src/lib/api.ts``FacetEntry.handle``tag`; drop `slug` +
78+
`namespace` (never on the wire).
79+
- `apps/web/src/components/FacetSidebar.tsx` — read `e.tag`; sort active
80+
selections to the top; drop the Stages tab + props.
81+
- `apps/web/src/components/StageFilterRow.tsx` (new) — horizontal pill row
82+
using `STAGES` metadata.
83+
- `apps/web/src/screens/ProjectsIndex.tsx` — render `StageFilterRow` above
84+
the search box; drop stage props from `FacetSidebar`.
85+
- The other two `FacetSidebar` consumers (`PeopleIndex`, `HelpWantedIndex`)
86+
already passed only the tag-related props, so they're unaffected.
87+
88+
### 5. Tests
89+
90+
- **`apps/api/tests/project-filters.test.ts` (new)** — 9 cases drive
91+
`ProjectService` directly against a hand-built `InMemoryState`. Covers:
92+
OR-within-namespace, AND-across-namespaces, the combined case, unknown
93+
handles silently dropped, byTopic widens when topic is filtered, byTech
94+
narrows toward topic, byStage excludes its own filter, selected-tag
95+
pinning with count 0, and `tag` (not `handle`) in the response.
96+
- **`apps/web/tests/ProjectsIndex.test.tsx`** — pre-existing tests
97+
updated to the new facet shape (was using stale `{ handle, slug, … }`
98+
mock that didn't match the API). New cases: sidebar chip renders with
99+
count + is distinct from other chips (would have caught the original
100+
bug); clicking a topic chip lands `tag=topic.transit` in the next
101+
`/api/projects` fetch URL (would have caught the empty-slug `tag=topic.`
102+
symptom); stage pill in the row above results adds to `stageIn=`;
103+
toggle-twice flips the chip off.
104+
- **`apps/api/tests/read-api.test.ts`** — the existing "filters by tag
105+
AND facets still reflect unfiltered corpus" test updated to match the
106+
new spec; the data check (filtered list contains the project) still
107+
holds, the facet check is reframed as "selected tag pinned/included
108+
in its namespace."
109+
110+
## Validation
111+
112+
- [x] Spec updated: tag OR-within / AND-across + facet self-exclusion +
113+
stage row.
114+
- [x] `ProjectService.list` honors OR-within-namespace and AND-across-namespaces.
115+
- [x] `computeProjectFacets` returns filtered counts per the spec, pins
116+
selected tags below top-10.
117+
- [x] SPA `FacetEntry` aligned with API; `FacetSidebar` reads `e.tag`.
118+
- [x] `StageFilterRow` renders above results with stage filter state.
119+
- [x] New API tests: 9/9. New SPA tests: 7/7 in ProjectsIndex.
120+
- [x] Full sweeps: api 397/397, web 73/73, shared 75/75.
121+
- [x] `npm run type-check && npm run lint` clean.
122+
123+
## Risks / unknowns
124+
125+
- **Behavior change on a public endpoint.** External consumers of
126+
`/api/projects?tag=...` who relied on AND-across-repeats semantics
127+
would observe a different result. Acceptable: the spec change is
128+
documented; in practice the only consumer is our own SPA, which was
129+
already broken on this code path.
130+
- **Facet computation is now per-request.** Before: a global cache
131+
invalidated on writes. After: O(projects × tags) per `/api/projects`
132+
request. Civic-scale corpus (~500 projects, ~5K tag assignments)
133+
makes this trivially fast (~ms) — no caching needed for v1. Worth
134+
noting if the corpus grows by 10x.
135+
- **PeopleIndex still uses the OLD unfiltered/cached facet path.** Same
136+
data-shape fix benefits it (the SPA bug was uniform); the OR-within /
137+
filtered-counts treatment isn't applied. Followup if/when noticed.
138+
139+
## Notes
140+
141+
Shipped clean — all 9 new API tests + 7 SPA tests pass on the first sweep;
142+
full sweeps (api 397, web 73, shared 75) clean; lint + type-check green.
143+
144+
Surprises:
145+
146+
- **The data-shape bug had been live since the SPA's first feature drop**
147+
(commits `2f0a62c` and `427c2bf`), not a recent regression. The `FacetEntry`
148+
type was written against a proposed API shape that never matched what the
149+
backend actually shipped. No test exercised the click → URL → fresh-fetch
150+
path against real-shaped data, so the bug sat dormant until a user clicked
151+
a chip. Lesson worth memorializing: tests against handcrafted mock data
152+
must match the spec's wire shape, not the SPA's TypeScript types — types
153+
describe what the SPA *wants*, the spec describes what it *gets*.
154+
- **Per-request facet computation is fast enough at civic scale.** The
155+
pre-fix `facets.ts` cached a global facet object invalidated on every
156+
write. The new per-request path computes 5 separate filter passes
157+
(the main listing + 4 facet exclusions) over the project set. At
158+
~500 projects + ~5K tag assignments this is ~1-2ms total — well below
159+
the existing route's overhead. Caching isn't worth the complexity.
160+
- **Selected-tag pinning matters more than I expected.** Without it, a
161+
user picks `topic.education` (one project tagged), then also picks
162+
`tech.python` (two projects, but no overlap with education) — the
163+
education facet would disappear from the sidebar because count=0, and
164+
the user wouldn't even know it's still applied. Pinning keeps the
165+
selection visible at count=0.
166+
- **OR-within / AND-across is also the right default for `?tag=` API
167+
consumers**, not just the SPA. Anyone using the public API directly
168+
who wants strict-AND semantics can still get it by issuing one tag
169+
per request — but the natural URL `?tag=a&tag=b` now matches the
170+
user expectation across pretty much every faceted-search product
171+
shipped in the last decade.
172+
173+
## Follow-ups
174+
175+
- **People + Help-Wanted indexes have the same UX bug.** The SPA fix to
176+
`FacetEntry` benefits all three sidebars uniformly (the click → URL
177+
path now produces correct handles everywhere). But the OR-within /
178+
filtered-counts treatment is still projects-only on the backend.
179+
*Tracked as* — follow-up when someone notices the people-list facets
180+
feel off, or as part of a broader tag-search audit.
181+
- **Facet caching at scale.** If the project corpus crosses ~10K, the
182+
per-request facet computation will start to show up in route timing.
183+
*None* — civic-scale is fine; revisit when the data shows the need.
184+
- **Spec drift auditor coverage.** The original mismatch (SPA's
185+
`FacetEntry` vs the spec) would have been caught by an auditor pass
186+
that compared TS types in `apps/web/src/lib/api.ts` against the JSON
187+
example blocks in `specs/api/*.md`. *None* for now — manual review on
188+
the next audit pass.

0 commit comments

Comments
 (0)