Skip to content

Commit ef50267

Browse files
fix(filters): project filters — OR-within/AND-across tags + filtered 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>
1 parent 741aa2c commit ef50267

11 files changed

Lines changed: 831 additions & 238 deletions

File tree

apps/api/src/services/project.ts

Lines changed: 125 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import type { HelpWantedRole, Person, Project, ProjectMembership, Tag } from '@cfp/shared/schemas';
55
import type { InMemoryState } from '../store/memory/state.js';
66
import type { FtsEngine } from '../store/fts.js';
7-
import { getProjectFacets, type ProjectFacets } from '../store/memory/facets.js';
7+
import { computeProjectFacets, type ProjectFacets } from '../store/memory/facets.js';
88
import type { CallerSession } from './permissions.js';
99
import { computeProjectPermissions } from './permissions.js';
1010
import {
@@ -81,113 +81,166 @@ export class ProjectService {
8181
const sortSpec = parseSortSpec(opts.sort ?? '-updatedAt');
8282
if (!sortSpec) return { error: 'invalid_sort' };
8383

84-
// Get the facets from the unfiltered corpus BEFORE applying filters
85-
const facets = getProjectFacets(this.#state);
84+
// ---- Resolve all filter inputs ----
8685

87-
// FTS filter
8886
let ftsSlugs: Set<string> | null = null;
8987
if (opts.q) {
9088
const slugs = this.#fts.searchProjects(opts.q);
9189
ftsSlugs = new Set(slugs);
9290
}
9391

94-
// memberSlug → personId for membership filter
9592
let memberPersonId: string | undefined;
9693
if (opts.memberSlug) {
9794
memberPersonId = this.#state.personIdBySlug.get(opts.memberSlug);
98-
if (!memberPersonId) {
99-
return { items: [], totalItems: 0, facets };
100-
}
95+
// Empty match below if memberPersonId is undefined — predicate returns false.
10196
}
10297

103-
// maintainer slug → id
10498
let maintainerPersonId: string | undefined;
10599
if (opts.maintainer) {
106100
maintainerPersonId = this.#state.personIdBySlug.get(opts.maintainer);
107-
if (!maintainerPersonId) {
108-
return { items: [], totalItems: 0, facets };
109-
}
110101
}
111102

112-
// tag handles → tag IDs
113-
let filterTagIds: Set<string> | undefined;
114-
if (opts.tag && opts.tag.length > 0) {
115-
filterTagIds = new Set();
103+
// Tag filters grouped by namespace. OR within namespace, AND across
104+
// namespaces (per specs/api/projects.md). Unknown handles are dropped
105+
// silently — same behavior as the pre-OR-semantics implementation.
106+
const tagsByNamespace = new Map<string, Set<string>>();
107+
if (opts.tag) {
116108
for (const handle of opts.tag) {
109+
const dot = handle.indexOf('.');
110+
if (dot < 0) continue;
111+
const ns = handle.slice(0, dot);
117112
const tagId = this.#state.tagIdByHandle.get(handle);
118-
if (tagId) filterTagIds.add(tagId);
113+
if (!tagId) continue;
114+
let set = tagsByNamespace.get(ns);
115+
if (!set) {
116+
set = new Set();
117+
tagsByNamespace.set(ns, set);
118+
}
119+
set.add(tagId);
119120
}
120121
}
121122

122-
// stageIn
123-
const stageInSet = opts.stageIn ? new Set(opts.stageIn) : null;
124-
125-
const filtered = [...this.#state.projects.values()].filter((p) => {
126-
// Soft-delete filter
127-
if (p.deletedAt && !opts.includeDeleted) return false;
128-
129-
// FTS
130-
if (ftsSlugs && !ftsSlugs.has(p.slug)) return false;
131-
132-
// Stage filters
133-
if (opts.stage && p.stage !== opts.stage) return false;
134-
if (stageInSet && !stageInSet.has(p.stage)) return false;
135-
136-
// Featured
137-
if (opts.featured !== undefined && p.featured !== opts.featured) return false;
138-
139-
// Maintainer
140-
if (maintainerPersonId && p.maintainerId !== maintainerPersonId) return false;
141-
142-
// Tag filter (AND semantics)
143-
if (filterTagIds && filterTagIds.size > 0) {
144-
const projectAssignments = this.#state.tagAssignmentsByTaggable.get(p.id);
145-
if (!projectAssignments) return false;
146-
const projectTagIds = new Set(
147-
[...projectAssignments]
148-
.map((taId) => this.#state.tagAssignments.get(taId)?.tagId)
149-
.filter((id): id is string => id !== undefined),
150-
);
151-
for (const tagId of filterTagIds) {
152-
if (!projectTagIds.has(tagId)) return false;
123+
const stageInSet = opts.stageIn && opts.stageIn.length > 0 ? new Set(opts.stageIn) : null;
124+
125+
// Resolve a project's tag IDs once — used by both the main filter and
126+
// the per-namespace facet computation below.
127+
const projectTagIdsCache = new Map<string, Set<string>>();
128+
const getProjectTagIds = (projectId: string): Set<string> => {
129+
let cached = projectTagIdsCache.get(projectId);
130+
if (cached) return cached;
131+
const assignments = this.#state.tagAssignmentsByTaggable.get(projectId);
132+
cached = new Set();
133+
if (assignments) {
134+
for (const taId of assignments) {
135+
const ta = this.#state.tagAssignments.get(taId);
136+
if (ta && ta.taggableType === 'project') cached.add(ta.tagId);
153137
}
154138
}
139+
projectTagIdsCache.set(projectId, cached);
140+
return cached;
141+
};
142+
143+
// ---- Predicate factory ----
144+
//
145+
// Each facet group needs the project set filtered by every criterion
146+
// *except* the one being widened ("self-namespace exclusion" per
147+
// specs/api/projects.md → Counts reflect the filtered corpus). The
148+
// factory takes optional exclusions so we can build:
149+
//
150+
// matches() — full filter (main listing + totalItems)
151+
// matches({ excludeStage: true }) — for byStage facet
152+
// matches({ excludeTagNs: 'topic' }) — for byTopic facet
153+
// ...
154+
155+
const buildMatcher = (excluded: {
156+
excludeStage?: boolean;
157+
excludeTagNs?: string;
158+
} = {}) => {
159+
return (p: Project): boolean => {
160+
if (p.deletedAt && !opts.includeDeleted) return false;
161+
if (ftsSlugs && !ftsSlugs.has(p.slug)) return false;
162+
if (!excluded.excludeStage) {
163+
if (opts.stage && p.stage !== opts.stage) return false;
164+
if (stageInSet && !stageInSet.has(p.stage)) return false;
165+
}
166+
if (opts.featured !== undefined && p.featured !== opts.featured) return false;
167+
if (opts.maintainer && p.maintainerId !== maintainerPersonId) return false;
168+
169+
// Tag predicate — for each namespace's filter set the project must
170+
// include at least one matching tag (OR within namespace). Across
171+
// namespaces the predicate is AND.
172+
if (tagsByNamespace.size > 0) {
173+
let projectTagIds: Set<string> | null = null;
174+
for (const [ns, tagIds] of tagsByNamespace) {
175+
if (ns === excluded.excludeTagNs) continue;
176+
if (!projectTagIds) projectTagIds = getProjectTagIds(p.id);
177+
let hit = false;
178+
for (const tId of tagIds) {
179+
if (projectTagIds.has(tId)) {
180+
hit = true;
181+
break;
182+
}
183+
}
184+
if (!hit) return false;
185+
}
186+
}
155187

156-
// Member filter
157-
if (memberPersonId) {
158-
const personMemberships = this.#state.membershipsByPerson.get(memberPersonId);
159-
if (!personMemberships) return false;
160-
const isMember = [...personMemberships].some(
161-
(mId) => this.#state.projectMemberships.get(mId)?.projectId === p.id,
162-
);
163-
if (!isMember) return false;
164-
}
188+
if (opts.memberSlug) {
189+
if (!memberPersonId) return false;
190+
const personMemberships = this.#state.membershipsByPerson.get(memberPersonId);
191+
if (!personMemberships) return false;
192+
let found = false;
193+
for (const mId of personMemberships) {
194+
if (this.#state.projectMemberships.get(mId)?.projectId === p.id) {
195+
found = true;
196+
break;
197+
}
198+
}
199+
if (!found) return false;
200+
}
165201

166-
// Help-wanted filter
167-
if (opts.helpWanted) {
168-
const roles = this.#state.helpWantedByProject.get(p.id);
169-
if (!roles) return false;
170-
const hasOpen = [...roles].some(
171-
(rId) => this.#state.helpWantedRoles.get(rId)?.status === 'open',
172-
);
173-
if (!hasOpen) return false;
174-
}
202+
if (opts.helpWanted) {
203+
const roles = this.#state.helpWantedByProject.get(p.id);
204+
if (!roles) return false;
205+
let hasOpen = false;
206+
for (const rId of roles) {
207+
if (this.#state.helpWantedRoles.get(rId)?.status === 'open') {
208+
hasOpen = true;
209+
break;
210+
}
211+
}
212+
if (!hasOpen) return false;
213+
}
175214

176-
return true;
177-
});
215+
return true;
216+
};
217+
};
178218

179-
// Sort
180-
filtered.sort((a, b) => compareProjects(a, b, sortSpec));
219+
const allProjects = [...this.#state.projects.values()];
181220

221+
// ---- Main listing ----
222+
223+
const filtered = allProjects.filter(buildMatcher());
224+
filtered.sort((a, b) => compareProjects(a, b, sortSpec));
182225
const totalItems = filtered.length;
183226

184-
// Pagination
185227
const page = Math.max(1, opts.page ?? 1);
186228
const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30));
187229
const slice = filtered.slice((page - 1) * perPage, page * perPage);
188-
189230
const items = slice.map((project) => this.#serializeListItem(project));
190231

232+
// ---- Facets ----
233+
234+
const facets = computeProjectFacets({
235+
tags: this.#state.tags,
236+
projectsExcludingStage: allProjects.filter(buildMatcher({ excludeStage: true })),
237+
projectsExcludingTopic: allProjects.filter(buildMatcher({ excludeTagNs: 'topic' })),
238+
projectsExcludingTech: allProjects.filter(buildMatcher({ excludeTagNs: 'tech' })),
239+
projectsExcludingEvent: allProjects.filter(buildMatcher({ excludeTagNs: 'event' })),
240+
getProjectTagIds,
241+
selectedTagsByNamespace: tagsByNamespace,
242+
});
243+
191244
return { items, totalItems, facets };
192245
}
193246

0 commit comments

Comments
 (0)