|
| 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