diff --git a/apps/api/src/services/project.ts b/apps/api/src/services/project.ts index 43135d9..2d75ea7 100644 --- a/apps/api/src/services/project.ts +++ b/apps/api/src/services/project.ts @@ -4,7 +4,7 @@ import type { HelpWantedRole, Person, Project, ProjectMembership, Tag } from '@cfp/shared/schemas'; import type { InMemoryState } from '../store/memory/state.js'; import type { FtsEngine } from '../store/fts.js'; -import { getProjectFacets, type ProjectFacets } from '../store/memory/facets.js'; +import { computeProjectFacets, type ProjectFacets } from '../store/memory/facets.js'; import type { CallerSession } from './permissions.js'; import { computeProjectPermissions } from './permissions.js'; import { @@ -81,113 +81,166 @@ export class ProjectService { const sortSpec = parseSortSpec(opts.sort ?? '-updatedAt'); if (!sortSpec) return { error: 'invalid_sort' }; - // Get the facets from the unfiltered corpus BEFORE applying filters - const facets = getProjectFacets(this.#state); + // ---- Resolve all filter inputs ---- - // FTS filter let ftsSlugs: Set | null = null; if (opts.q) { const slugs = this.#fts.searchProjects(opts.q); ftsSlugs = new Set(slugs); } - // memberSlug → personId for membership filter let memberPersonId: string | undefined; if (opts.memberSlug) { memberPersonId = this.#state.personIdBySlug.get(opts.memberSlug); - if (!memberPersonId) { - return { items: [], totalItems: 0, facets }; - } + // Empty match below if memberPersonId is undefined — predicate returns false. } - // maintainer slug → id let maintainerPersonId: string | undefined; if (opts.maintainer) { maintainerPersonId = this.#state.personIdBySlug.get(opts.maintainer); - if (!maintainerPersonId) { - return { items: [], totalItems: 0, facets }; - } } - // tag handles → tag IDs - let filterTagIds: Set | undefined; - if (opts.tag && opts.tag.length > 0) { - filterTagIds = new Set(); + // Tag filters grouped by namespace. OR within namespace, AND across + // namespaces (per specs/api/projects.md). Unknown handles are dropped + // silently — same behavior as the pre-OR-semantics implementation. + const tagsByNamespace = new Map>(); + if (opts.tag) { for (const handle of opts.tag) { + const dot = handle.indexOf('.'); + if (dot < 0) continue; + const ns = handle.slice(0, dot); const tagId = this.#state.tagIdByHandle.get(handle); - if (tagId) filterTagIds.add(tagId); + if (!tagId) continue; + let set = tagsByNamespace.get(ns); + if (!set) { + set = new Set(); + tagsByNamespace.set(ns, set); + } + set.add(tagId); } } - // stageIn - const stageInSet = opts.stageIn ? new Set(opts.stageIn) : null; - - const filtered = [...this.#state.projects.values()].filter((p) => { - // Soft-delete filter - if (p.deletedAt && !opts.includeDeleted) return false; - - // FTS - if (ftsSlugs && !ftsSlugs.has(p.slug)) return false; - - // Stage filters - if (opts.stage && p.stage !== opts.stage) return false; - if (stageInSet && !stageInSet.has(p.stage)) return false; - - // Featured - if (opts.featured !== undefined && p.featured !== opts.featured) return false; - - // Maintainer - if (maintainerPersonId && p.maintainerId !== maintainerPersonId) return false; - - // Tag filter (AND semantics) - if (filterTagIds && filterTagIds.size > 0) { - const projectAssignments = this.#state.tagAssignmentsByTaggable.get(p.id); - if (!projectAssignments) return false; - const projectTagIds = new Set( - [...projectAssignments] - .map((taId) => this.#state.tagAssignments.get(taId)?.tagId) - .filter((id): id is string => id !== undefined), - ); - for (const tagId of filterTagIds) { - if (!projectTagIds.has(tagId)) return false; + const stageInSet = opts.stageIn && opts.stageIn.length > 0 ? new Set(opts.stageIn) : null; + + // Resolve a project's tag IDs once — used by both the main filter and + // the per-namespace facet computation below. + const projectTagIdsCache = new Map>(); + const getProjectTagIds = (projectId: string): Set => { + let cached = projectTagIdsCache.get(projectId); + if (cached) return cached; + const assignments = this.#state.tagAssignmentsByTaggable.get(projectId); + cached = new Set(); + if (assignments) { + for (const taId of assignments) { + const ta = this.#state.tagAssignments.get(taId); + if (ta && ta.taggableType === 'project') cached.add(ta.tagId); } } + projectTagIdsCache.set(projectId, cached); + return cached; + }; + + // ---- Predicate factory ---- + // + // Each facet group needs the project set filtered by every criterion + // *except* the one being widened ("self-namespace exclusion" per + // specs/api/projects.md → Counts reflect the filtered corpus). The + // factory takes optional exclusions so we can build: + // + // matches() — full filter (main listing + totalItems) + // matches({ excludeStage: true }) — for byStage facet + // matches({ excludeTagNs: 'topic' }) — for byTopic facet + // ... + + const buildMatcher = (excluded: { + excludeStage?: boolean; + excludeTagNs?: string; + } = {}) => { + return (p: Project): boolean => { + if (p.deletedAt && !opts.includeDeleted) return false; + if (ftsSlugs && !ftsSlugs.has(p.slug)) return false; + if (!excluded.excludeStage) { + if (opts.stage && p.stage !== opts.stage) return false; + if (stageInSet && !stageInSet.has(p.stage)) return false; + } + if (opts.featured !== undefined && p.featured !== opts.featured) return false; + if (opts.maintainer && p.maintainerId !== maintainerPersonId) return false; + + // Tag predicate — for each namespace's filter set the project must + // include at least one matching tag (OR within namespace). Across + // namespaces the predicate is AND. + if (tagsByNamespace.size > 0) { + let projectTagIds: Set | null = null; + for (const [ns, tagIds] of tagsByNamespace) { + if (ns === excluded.excludeTagNs) continue; + if (!projectTagIds) projectTagIds = getProjectTagIds(p.id); + let hit = false; + for (const tId of tagIds) { + if (projectTagIds.has(tId)) { + hit = true; + break; + } + } + if (!hit) return false; + } + } - // Member filter - if (memberPersonId) { - const personMemberships = this.#state.membershipsByPerson.get(memberPersonId); - if (!personMemberships) return false; - const isMember = [...personMemberships].some( - (mId) => this.#state.projectMemberships.get(mId)?.projectId === p.id, - ); - if (!isMember) return false; - } + if (opts.memberSlug) { + if (!memberPersonId) return false; + const personMemberships = this.#state.membershipsByPerson.get(memberPersonId); + if (!personMemberships) return false; + let found = false; + for (const mId of personMemberships) { + if (this.#state.projectMemberships.get(mId)?.projectId === p.id) { + found = true; + break; + } + } + if (!found) return false; + } - // Help-wanted filter - if (opts.helpWanted) { - const roles = this.#state.helpWantedByProject.get(p.id); - if (!roles) return false; - const hasOpen = [...roles].some( - (rId) => this.#state.helpWantedRoles.get(rId)?.status === 'open', - ); - if (!hasOpen) return false; - } + if (opts.helpWanted) { + const roles = this.#state.helpWantedByProject.get(p.id); + if (!roles) return false; + let hasOpen = false; + for (const rId of roles) { + if (this.#state.helpWantedRoles.get(rId)?.status === 'open') { + hasOpen = true; + break; + } + } + if (!hasOpen) return false; + } - return true; - }); + return true; + }; + }; - // Sort - filtered.sort((a, b) => compareProjects(a, b, sortSpec)); + const allProjects = [...this.#state.projects.values()]; + // ---- Main listing ---- + + const filtered = allProjects.filter(buildMatcher()); + filtered.sort((a, b) => compareProjects(a, b, sortSpec)); const totalItems = filtered.length; - // Pagination const page = Math.max(1, opts.page ?? 1); const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30)); const slice = filtered.slice((page - 1) * perPage, page * perPage); - const items = slice.map((project) => this.#serializeListItem(project)); + // ---- Facets ---- + + const facets = computeProjectFacets({ + tags: this.#state.tags, + projectsExcludingStage: allProjects.filter(buildMatcher({ excludeStage: true })), + projectsExcludingTopic: allProjects.filter(buildMatcher({ excludeTagNs: 'topic' })), + projectsExcludingTech: allProjects.filter(buildMatcher({ excludeTagNs: 'tech' })), + projectsExcludingEvent: allProjects.filter(buildMatcher({ excludeTagNs: 'event' })), + getProjectTagIds, + selectedTagsByNamespace: tagsByNamespace, + }); + return { items, totalItems, facets }; } diff --git a/apps/api/src/store/memory/facets.ts b/apps/api/src/store/memory/facets.ts index 5745bdc..481d64f 100644 --- a/apps/api/src/store/memory/facets.ts +++ b/apps/api/src/store/memory/facets.ts @@ -1,9 +1,19 @@ /** - * Facet cache — computes and caches the tag-group and stage counts for the - * projects list sidebar. Counts are over the UNFILTERED corpus per spec. + * Facet computation for the projects-list + people-list sidebars. * - * Invalidated by write-api after any project or tag-assignment mutation. + * **Projects** use `computeProjectFacets` — invoked per request by + * ProjectService.list. Each facet group's counts are computed over the + * project set filtered by every criterion *except* the one being + * widened (self-namespace exclusion). Implements the contract in + * specs/api/projects.md → "Counts reflect the filtered corpus with + * self-namespace exclusion." + * + * **People** still use the simpler cached unfiltered approach + * (`getPeopleFacets`). That's a deliberate scope limit — the projects + * filter UX was the regressed one; the people sidebar can adopt the + * same pattern when someone notices it matters there. */ +import type { Project, Tag } from '@cfp/shared/schemas'; import type { InMemoryState } from './state.js'; export interface TagFacet { @@ -29,75 +39,142 @@ export interface PeopleFacets { readonly byTech: TagFacet[]; } -let cachedProjectFacets: ProjectFacets | null = null; -let cachedPeopleFacets: PeopleFacets | null = null; - -export function invalidateFacets(): void { - cachedProjectFacets = null; - cachedPeopleFacets = null; +const STAGE_ORDER = [ + 'commenting', + 'bootstrapping', + 'prototyping', + 'testing', + 'maintaining', + 'drifting', + 'hibernating', +]; + +type FacetNamespace = 'topic' | 'tech' | 'event'; + +export interface ComputeProjectFacetsInput { + readonly tags: ReadonlyMap; + readonly projectsExcludingStage: readonly Project[]; + readonly projectsExcludingTopic: readonly Project[]; + readonly projectsExcludingTech: readonly Project[]; + readonly projectsExcludingEvent: readonly Project[]; + /** Returns the set of tag IDs assigned to a given project (cached upstream). */ + readonly getProjectTagIds: (projectId: string) => ReadonlySet; + /** + * Selected tag filters keyed by namespace. Used to ensure active + * selections appear in their namespace's facet list even when they'd + * otherwise fall below the top-10 cut after the self-exclusion count + * narrows. + */ + readonly selectedTagsByNamespace: ReadonlyMap>; } -export function getProjectFacets(state: InMemoryState): ProjectFacets { - if (cachedProjectFacets) return cachedProjectFacets; +/** + * Compute the project-list facet response per the spec. + * + * Each tag-namespace facet (topic/tech/event) is counted over the + * project set that has every filter applied *except* tag filters in + * that namespace. The stage facet is counted over the set with every + * filter applied except `stage`/`stageIn`. + * + * Active selections are pinned into their namespace's facet list when + * they fall below the top-10 cut so the SPA can still render them as + * still-selected. + */ +export function computeProjectFacets(input: ComputeProjectFacetsInput): ProjectFacets { + const byTopic = computeTagNamespaceFacet( + 'topic', + input.projectsExcludingTopic, + input.tags, + input.getProjectTagIds, + input.selectedTagsByNamespace.get('topic') ?? null, + ); + const byTech = computeTagNamespaceFacet( + 'tech', + input.projectsExcludingTech, + input.tags, + input.getProjectTagIds, + input.selectedTagsByNamespace.get('tech') ?? null, + ); + const byEvent = computeTagNamespaceFacet( + 'event', + input.projectsExcludingEvent, + input.tags, + input.getProjectTagIds, + input.selectedTagsByNamespace.get('event') ?? null, + ); - const topicCounts = new Map(); - const techCounts = new Map(); - const eventCounts = new Map(); const stageCounts = new Map(); - - // Count projects per stage (non-deleted only) - for (const project of state.projects.values()) { - if (project.deletedAt) continue; - stageCounts.set(project.stage, (stageCounts.get(project.stage) ?? 0) + 1); + for (const p of input.projectsExcludingStage) { + stageCounts.set(p.stage, (stageCounts.get(p.stage) ?? 0) + 1); } + const byStage: StageFacet[] = STAGE_ORDER.filter((s) => stageCounts.has(s)).map((s) => ({ + stage: s, + count: stageCounts.get(s)!, + })); - // Count tag assignments for projects (non-deleted projects only) - const nonDeletedProjectIds = new Set( - [...state.projects.values()].filter((p) => !p.deletedAt).map((p) => p.id), - ); - - for (const ta of state.tagAssignments.values()) { - if (ta.taggableType !== 'project') continue; - if (!nonDeletedProjectIds.has(ta.taggableId)) continue; - - const tag = state.tags.get(ta.tagId); - if (!tag) continue; + return { byTopic, byTech, byEvent, byStage }; +} - const handle = `${tag.namespace}.${tag.slug}`; - const target = - tag.namespace === 'topic' ? topicCounts - : tag.namespace === 'tech' ? techCounts - : tag.namespace === 'event' ? eventCounts - : null; +function computeTagNamespaceFacet( + namespace: FacetNamespace, + baseProjects: readonly Project[], + allTags: ReadonlyMap, + getProjectTagIds: (projectId: string) => ReadonlySet, + selectedTagIds: ReadonlySet | null, +): TagFacet[] { + // Aggregate counts: tagId → count over baseProjects + const counts = new Map(); + for (const p of baseProjects) { + const projectTagIds = getProjectTagIds(p.id); + for (const tagId of projectTagIds) { + const tag = allTags.get(tagId); + if (!tag || tag.namespace !== namespace) continue; + counts.set(tagId, (counts.get(tagId) ?? 0) + 1); + } + } - if (!target) continue; - const existing = target.get(handle); - if (existing) { - existing.count++; - } else { - target.set(handle, { title: tag.title, count: 1 }); + // Sort by count desc, then by tag title for stability. + const sorted = [...counts.entries()] + .map(([tagId, count]) => { + const tag = allTags.get(tagId); + return tag ? { tagId, tag, count } : null; + }) + .filter((x): x is { tagId: string; tag: Tag; count: number } => x !== null) + .sort((a, b) => b.count - a.count || a.tag.title.localeCompare(b.tag.title)); + + const top = sorted.slice(0, 10); + + // Pin any selected tags that fell below the top-10 cut. Selected tags + // with count 0 (no project matches them under the current filter set, + // including OR-widening within the namespace) still get pinned with + // count 0 so the SPA renders them as selected. + if (selectedTagIds && selectedTagIds.size > 0) { + const topIds = new Set(top.map((e) => e.tagId)); + for (const selId of selectedTagIds) { + if (topIds.has(selId)) continue; + const tag = allTags.get(selId); + if (!tag || tag.namespace !== namespace) continue; + top.push({ tagId: selId, tag, count: counts.get(selId) ?? 0 }); } } - const toSortedFacets = (m: Map): TagFacet[] => - [...m.entries()] - .map(([tag, { title, count }]) => ({ tag, title, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 10); + return top.map(({ tag, count }) => ({ + tag: `${tag.namespace}.${tag.slug}`, + title: tag.title, + count, + })); +} - const stageOrder = ['commenting', 'bootstrapping', 'prototyping', 'testing', 'maintaining', 'drifting', 'hibernating']; - const byStage: StageFacet[] = stageOrder - .filter((s) => stageCounts.has(s)) - .map((s) => ({ stage: s, count: stageCounts.get(s)! })); +// --------------------------------------------------------------------------- +// People facets — still simple, still cached, still unfiltered. When the +// people-list filter UX gets the same treatment, replace with the same +// per-request pattern as computeProjectFacets above. +// --------------------------------------------------------------------------- - cachedProjectFacets = { - byTopic: toSortedFacets(topicCounts), - byTech: toSortedFacets(techCounts), - byEvent: toSortedFacets(eventCounts), - byStage, - }; +let cachedPeopleFacets: PeopleFacets | null = null; - return cachedProjectFacets; +export function invalidateFacets(): void { + cachedPeopleFacets = null; } export function getPeopleFacets(state: InMemoryState): PeopleFacets { @@ -119,9 +196,11 @@ export function getPeopleFacets(state: InMemoryState): PeopleFacets { const handle = `${tag.namespace}.${tag.slug}`; const target = - tag.namespace === 'topic' ? topicCounts - : tag.namespace === 'tech' ? techCounts - : null; + tag.namespace === 'topic' + ? topicCounts + : tag.namespace === 'tech' + ? techCounts + : null; if (!target) continue; const existing = target.get(handle); diff --git a/apps/api/tests/project-filters.test.ts b/apps/api/tests/project-filters.test.ts new file mode 100644 index 0000000..936cc69 --- /dev/null +++ b/apps/api/tests/project-filters.test.ts @@ -0,0 +1,265 @@ +/** + * Tests for the project filter + facet contract per + * specs/api/projects.md: + * + * - Tag filters: OR within namespace, AND across namespaces. + * - Facets reflect the filtered corpus with self-namespace exclusion + * so the user can widen within a facet group. + * - Active selections are pinned into their namespace's facet list + * when they fall below top 10. + * + * Drives ProjectService directly against a hand-built InMemoryState so + * the test stays focused on the filter algebra without TOML/gitsheets + * round-trips. The HTTP wiring is covered by read-api.test.ts. + */ +import { describe, expect, it } from 'vitest'; + +import { + createEmptyState, + indexProject, + indexTag, + indexTagAssignment, +} from '../src/store/memory/state.js'; +import type { InMemoryState } from '../src/store/memory/state.js'; +import { ProjectService } from '../src/services/project.js'; +import type { FtsEngine } from '../src/store/fts.js'; +import type { Project, Tag, TagAssignment } from '@cfp/shared/schemas'; + +// --------------------------------------------------------------------------- +// Test fixtures — six projects across three topic tags, two tech tags, +// three stages. Lets us cover: single namespace OR, cross-namespace AND, +// stage filter facet exclusion, and a low-popularity tag pin scenario. +// --------------------------------------------------------------------------- + +const NOW = '2026-06-01T00:00:00Z'; + +function makeProject(slug: string, overrides: Partial = {}): Project { + return { + id: `0195000-0000-7000-8000-${slug.padEnd(12, '0').slice(0, 12)}`, + slug, + title: slug, + summary: null, + overview: null, + stage: 'prototyping', + maintainerId: null, + featured: false, + deletedAt: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function makeTag(namespace: 'topic' | 'tech' | 'event', slug: string): Tag { + return { + id: `tag-${namespace}-${slug}`, + namespace, + slug, + title: slug.charAt(0).toUpperCase() + slug.slice(1), + createdAt: NOW, + updatedAt: NOW, + }; +} + +function makeAssignment(projectId: string, tagId: string): TagAssignment { + return { + id: `ta-${projectId}-${tagId}`, + tagId, + taggableType: 'project', + taggableId: projectId, + createdAt: NOW, + }; +} + +const noopFts: FtsEngine = { + reload: () => {}, + searchProjects: () => [], + searchBlogPosts: () => [], +}; + +function setupState(): { + state: InMemoryState; + service: ProjectService; + ids: Record; +} { + const state = createEmptyState(); + + // Tags + const tags = { + topicTransit: makeTag('topic', 'transit'), + topicMapping: makeTag('topic', 'mapping'), + topicEducation: makeTag('topic', 'education'), + techPython: makeTag('tech', 'python'), + techFlutter: makeTag('tech', 'flutter'), + }; + for (const t of Object.values(tags)) indexTag(state, t); + + // Projects + their tag assignments + stages + // + // alpha stage=testing tags: topic.transit + // bravo stage=testing tags: topic.mapping + // charlie stage=prototyping tags: topic.transit, tech.python + // delta stage=prototyping tags: topic.mapping, tech.flutter + // echo stage=maintaining tags: topic.transit, tech.python, tech.flutter + // foxtrot stage=maintaining tags: topic.education + // + const layout: Array<{ + slug: string; + stage: Project['stage']; + tagKeys: Array; + }> = [ + { slug: 'alpha', stage: 'testing', tagKeys: ['topicTransit'] }, + { slug: 'bravo', stage: 'testing', tagKeys: ['topicMapping'] }, + { slug: 'charlie', stage: 'prototyping', tagKeys: ['topicTransit', 'techPython'] }, + { slug: 'delta', stage: 'prototyping', tagKeys: ['topicMapping', 'techFlutter'] }, + { slug: 'echo', stage: 'maintaining', tagKeys: ['topicTransit', 'techPython', 'techFlutter'] }, + { slug: 'foxtrot', stage: 'maintaining', tagKeys: ['topicEducation'] }, + ]; + + const ids: Record = {}; + for (const { slug, stage, tagKeys } of layout) { + const p = makeProject(slug, { stage }); + ids[slug] = p.id; + indexProject(state, p); + for (const tk of tagKeys) { + indexTagAssignment(state, makeAssignment(p.id, tags[tk].id)); + } + } + + const service = new ProjectService(state, noopFts); + return { state, service, ids }; +} + +function listSlugs(result: { items: Array<{ slug: string }> } | { error: string }): string[] { + if ('error' in result) throw new Error(`unexpected error: ${result.error}`); + return result.items.map((p) => p.slug).sort(); +} + +// --------------------------------------------------------------------------- +// Tag filter semantics +// --------------------------------------------------------------------------- + +describe('ProjectService tag filter', () => { + it('OR within a single namespace — projects matching ANY tag in the namespace', () => { + const { service } = setupState(); + const result = service.list({ tag: ['topic.transit', 'topic.mapping'] }); + expect(listSlugs(result)).toEqual(['alpha', 'bravo', 'charlie', 'delta', 'echo']); + }); + + it('AND across namespaces — projects must match every namespace at least once', () => { + const { service } = setupState(); + const result = service.list({ tag: ['topic.transit', 'tech.python'] }); + // alpha = transit only (no tech) → excluded + // bravo = mapping only → excluded + // charlie = transit + python → included + // delta = mapping + flutter → excluded (transit absent) + // echo = transit + python + flutter → included + // foxtrot = education only → excluded + expect(listSlugs(result)).toEqual(['charlie', 'echo']); + }); + + it('combines OR-within and AND-across — (transit OR mapping) AND (python OR flutter)', () => { + const { service } = setupState(); + const result = service.list({ + tag: ['topic.transit', 'topic.mapping', 'tech.python', 'tech.flutter'], + }); + // charlie = transit + python → included + // delta = mapping + flutter → included + // echo = transit + python+flutter → included + expect(listSlugs(result)).toEqual(['charlie', 'delta', 'echo']); + }); + + it('unknown tag handles are silently dropped (no false zero-results)', () => { + const { service } = setupState(); + const result = service.list({ tag: ['topic.transit', 'topic.does-not-exist'] }); + // The known handle still narrows; the unknown one drops out of the + // tagsByNamespace map entirely (it leaves the namespace empty if it + // was the only handle, which then means no filter for that ns). + expect(listSlugs(result)).toEqual(['alpha', 'charlie', 'echo']); + }); +}); + +// --------------------------------------------------------------------------- +// Facet self-exclusion + pinning +// --------------------------------------------------------------------------- + +describe('ProjectService facets', () => { + it('byTopic excludes the topic tag filter — user can widen topic selection', () => { + const { service } = setupState(); + const result = service.list({ tag: ['topic.transit'] }); + if ('error' in result) throw new Error('unexpected error'); + + // The TOPIC facet should still include mapping + education, with counts + // narrowed by OTHER filters (none here, so full counts apply): + // transit appears on alpha, charlie, echo → count 3 (the active selection) + // mapping appears on bravo, delta → count 2 + // education appears on foxtrot → count 1 + const byTopicMap = new Map(result.facets.byTopic.map((f) => [f.tag, f.count])); + expect(byTopicMap.get('topic.transit')).toBe(3); + expect(byTopicMap.get('topic.mapping')).toBe(2); + expect(byTopicMap.get('topic.education')).toBe(1); + }); + + it('byTech narrows toward the active topic filter (filtered counts, not unfiltered)', () => { + const { service } = setupState(); + const result = service.list({ tag: ['topic.transit'] }); + if ('error' in result) throw new Error('unexpected error'); + + // byTech is computed over projects matching ALL filters except tech + // tags. With topic=transit: + // transit-tagged projects = alpha, charlie, echo + // of those: python tagged on charlie + echo → count 2 + // flutter tagged on echo only → count 1 + const byTechMap = new Map(result.facets.byTech.map((f) => [f.tag, f.count])); + expect(byTechMap.get('tech.python')).toBe(2); + expect(byTechMap.get('tech.flutter')).toBe(1); + }); + + it('byStage excludes the stage filter — user can widen stage selection', () => { + const { service } = setupState(); + const result = service.list({ stageIn: ['testing'] }); + if ('error' in result) throw new Error('unexpected error'); + + // Stage facet ignores the active stage filter — counts should reflect + // all 3 stages in the corpus. + const byStageMap = new Map(result.facets.byStage.map((f) => [f.stage, f.count])); + expect(byStageMap.get('testing')).toBe(2); + expect(byStageMap.get('prototyping')).toBe(2); + expect(byStageMap.get('maintaining')).toBe(2); + }); + + it('pins a selected tag with count 0 if no projects match under the rest of the filters', () => { + const { service } = setupState(); + // education is only on foxtrot. Filter by tech.python (only charlie + + // echo have it). topic.education's count under that filter is 0. + // It should still appear in byTopic because it's selected. + const result = service.list({ tag: ['topic.education', 'tech.python'] }); + if ('error' in result) throw new Error('unexpected error'); + + const educationFacet = result.facets.byTopic.find((f) => f.tag === 'topic.education'); + expect(educationFacet).toBeDefined(); + expect(educationFacet!.count).toBe(0); + + // And the listing itself has no matches (AND across namespaces: + // education AND python is empty). + expect(result.items).toHaveLength(0); + }); + + it('uses tag (not handle) field in the response shape', () => { + const { service } = setupState(); + const result = service.list({}); + if ('error' in result) throw new Error('unexpected error'); + + for (const f of result.facets.byTopic) { + expect(typeof f.tag).toBe('string'); + expect(f.tag).toMatch(/^topic\./); + } + for (const f of result.facets.byTech) { + expect(typeof f.tag).toBe('string'); + expect(f.tag).toMatch(/^tech\./); + } + for (const f of result.facets.byStage) { + expect(typeof f.stage).toBe('string'); + } + }); +}); diff --git a/apps/api/tests/read-api.test.ts b/apps/api/tests/read-api.test.ts index ee431c3..6912206 100644 --- a/apps/api/tests/read-api.test.ts +++ b/apps/api/tests/read-api.test.ts @@ -108,7 +108,7 @@ describe('GET /api/projects', () => { expect(body.data.some((p) => p.slug === fixtures.projectSlug)).toBe(true); }); - it('filters by tag AND facets still reflect unfiltered corpus', async () => { + it('filters by tag; selected tag is pinned into its namespace facet', async () => { const res = await app!.inject({ method: 'GET', url: `/api/projects?tag=${fixtures.tagHandle}`, @@ -121,7 +121,9 @@ describe('GET /api/projects', () => { // Filtered data contains our project expect(body.data.some((p) => p.slug === fixtures.projectSlug)).toBe(true); - // Facets are from unfiltered corpus — byTech should still have our tag + // Per the self-exclusion rule, byTech is computed over projects + // filtered by every criterion EXCEPT tech tags — the selected tag + // is still in the list (either naturally in the top 10 or pinned). expect(body.metadata.facets.byTech.some((f) => f.tag === fixtures.tagHandle)).toBe(true); }); diff --git a/apps/web/src/components/FacetSidebar.tsx b/apps/web/src/components/FacetSidebar.tsx index e5249b8..6542eaa 100644 --- a/apps/web/src/components/FacetSidebar.tsx +++ b/apps/web/src/components/FacetSidebar.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { TagChip } from '@/components/TagChip'; -import { STAGES, type Stage } from '@/components/StageBadge'; import { Link } from 'react-router'; import { cn } from '@/lib/utils'; import type { Facets, FacetEntry } from '@/lib/api'; @@ -10,9 +9,7 @@ interface FacetSidebarProps { facets: Facets | undefined; activeTags: string[]; onToggleTag: (handle: string) => void; - activeStages?: string[]; - onToggleStage?: (stage: string) => void; - tabs?: Array<'topic' | 'tech' | 'event' | 'stage'>; + tabs?: Array<'topic' | 'tech' | 'event'>; limit?: number; className?: string; } @@ -21,7 +18,6 @@ const NS_LABELS = { topic: 'Topics', tech: 'Tech', event: 'Events', - stage: 'Stages', } as const; const NS_SEE_ALL: Record = { @@ -41,15 +37,12 @@ export function FacetSidebar({ facets, activeTags, onToggleTag, - activeStages, - onToggleStage, - tabs = ['topic', 'tech', 'event', 'stage'], + tabs = ['topic', 'tech', 'event'], limit = 10, className, }: FacetSidebarProps) { const [tab, setTab] = useState(tabs[0] ?? 'topic'); const activeTagSet = new Set(activeTags); - const activeStageSet = new Set(activeStages ?? []); return ( ); diff --git a/apps/web/src/components/StageFilterRow.tsx b/apps/web/src/components/StageFilterRow.tsx new file mode 100644 index 0000000..bb04a7a --- /dev/null +++ b/apps/web/src/components/StageFilterRow.tsx @@ -0,0 +1,63 @@ +/** + * Horizontal pill row of project stages, rendered above the search + + * results column on the projects index. Stages are a 7-value fixed + * enum and benefit from being visible at a glance rather than tucked + * inside the tag sidebar's tab strip. See + * specs/screens/projects-index.md → "Stage row (top, above results)". + */ +import { STAGES, type Stage } from '@/components/StageBadge'; +import { cn } from '@/lib/utils'; +import type { Facets, FacetEntry } from '@/lib/api'; + +interface StageFilterRowProps { + facets: Facets | undefined; + activeStages: string[]; + onToggleStage: (stage: string) => void; + className?: string; +} + +function findStageFacet(facets: Facets | undefined, stage: string): FacetEntry | undefined { + return facets?.byStage?.find((f) => f.stage === stage); +} + +export function StageFilterRow({ + facets, + activeStages, + onToggleStage, + className, +}: StageFilterRowProps) { + const activeSet = new Set(activeStages); + + return ( +
+ {(Object.keys(STAGES) as Stage[]).map((s) => { + const facet = findStageFacet(facets, s); + const count = facet?.count ?? 0; + const meta = STAGES[s]; + const isActive = activeSet.has(s); + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 9cbe73e..3a7bcb9 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -25,11 +25,23 @@ export interface Facets { readonly byStage?: FacetEntry[]; } +/** + * A facet entry as returned by the projects/people/help-wanted list + * endpoints. Two shapes share this type: + * + * Tag facets — { tag: "topic.transit", title: "Transit", count: 27 } + * Stage facet — { stage: "prototyping", count: 41 } + * + * Per specs/api/projects.md (`tag` field, not `handle` or `slug` — + * those were never on the wire; the old shape was a stale type that + * never matched what the server actually emits). + */ export interface FacetEntry { - readonly handle?: string; + /** Tag handle in `.` form (tag facets only). */ + readonly tag?: string; + /** Tag display title (tag facets only). */ readonly title?: string; - readonly slug?: string; - readonly namespace?: string; + /** Stage slug, e.g. "prototyping" (stage facet only). */ readonly stage?: string; readonly count: number; } diff --git a/apps/web/src/screens/ProjectsIndex.tsx b/apps/web/src/screens/ProjectsIndex.tsx index 79f9f3d..805f407 100644 --- a/apps/web/src/screens/ProjectsIndex.tsx +++ b/apps/web/src/screens/ProjectsIndex.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ProjectCard } from '@/components/ProjectCard'; import { FacetSidebar } from '@/components/FacetSidebar'; +import { StageFilterRow } from '@/components/StageFilterRow'; import { Pagination } from '@/components/Pagination'; import { TagChip } from '@/components/TagChip'; import { STAGES, type Stage } from '@/components/StageBadge'; @@ -155,13 +156,21 @@ export function ProjectsIndex() { {/* Main */}
+ {/* Stage filter row (top, above search) — stages are a fixed + enum and benefit from being visible at a glance per + specs/screens/projects-index.md. */} + + {/* Search box */} { + if (input.startsWith('/api/auth/me')) { + return Promise.resolve(new Response(null, { status: 404 })); + } + if (input.startsWith('/api/projects')) { + return Promise.resolve( + new Response( + JSON.stringify( + mockPaginated([SAMPLE_PROJECT], { totalItems: 1, facets: SAMPLE_FACETS }), + ), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + return spy; +} + +function projectsUrls(spy: ReturnType): string[] { + return spy.mock.calls + .map((call) => String(call[0])) + .filter((url) => url.startsWith('/api/projects')); +} + describe('ProjectsIndex', () => { + let fetchSpy: ReturnType; + beforeEach(() => { - vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { - if (input.startsWith('/api/auth/me')) { - return Promise.resolve(new Response(null, { status: 404 })); - } - if (input.startsWith('/api/projects')) { - return Promise.resolve( - new Response(JSON.stringify(mockPaginated([SAMPLE_PROJECT], { totalItems: 1, facets: { byTech: [{ handle: 'tech.react', slug: 'react', title: 'React', count: 1 }] } })), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ); - } - return Promise.resolve(new Response(null, { status: 404 })); - }) as typeof fetch); + fetchSpy = installFetchSpy(); }); afterEach(() => { @@ -83,4 +121,92 @@ describe('ProjectsIndex', () => { }); expect(screen.queryByRole('link', { name: /add project/i })).not.toBeInTheDocument(); }); + + it('renders sidebar tag chips with the correct counts (no all-or-nothing handle bug)', async () => { + renderScreen( + + + , + { initialEntries: ['/projects'] }, + ); + // Default tab is Topics — wait for the chip to land. + const transitChip = await screen.findByRole('button', { name: /Transit/ }); + expect(transitChip).toBeInTheDocument(); + // The chip's accessible name should include its count. + expect(transitChip.textContent).toMatch(/5/); + // Mapping is also visible (and not represented by the same handle). + expect(screen.getByRole('button', { name: /Mapping/ })).toBeInTheDocument(); + }); + + it('clicking a topic chip adds the right tag handle to the URL and refetches', async () => { + renderScreen( + + + , + { initialEntries: ['/projects'] }, + ); + + const transitChip = await screen.findByRole('button', { name: /Transit/ }); + fireEvent.click(transitChip); + + await waitFor(() => { + const urls = projectsUrls(fetchSpy); + // The last /api/projects fetch must carry tag=topic.transit (and + // only topic.transit — NOT topic. which was the symptom of the + // FacetEntry shape bug). + const last = urls[urls.length - 1] ?? ''; + expect(last).toContain('tag=topic.transit'); + expect(last).not.toContain('tag=topic.&'); + expect(last).not.toMatch(/tag=topic\.$/); + }); + }); + + it('clicking a stage pill in the row above results adds the stage to the URL', async () => { + renderScreen( + + + , + { initialEntries: ['/projects'] }, + ); + + // The stage filter row is a region above the search box. + const stageRow = await screen.findByRole('group', { name: /stage filter/i }); + const prototypingPill = within(stageRow).getByRole('button', { name: /Prototyping/ }); + fireEvent.click(prototypingPill); + + await waitFor(() => { + const urls = projectsUrls(fetchSpy); + const last = urls[urls.length - 1] ?? ''; + expect(last).toContain('stageIn=prototyping'); + }); + }); + + it('clicking the same topic chip twice toggles it off (no infinite stacking)', async () => { + renderScreen( + + + , + { initialEntries: ['/projects'] }, + ); + + // Sidebar is labelled "Filters"; scope queries there so the "active + // filters" chip-row that appears after the first click doesn't shadow + // the sidebar chip when we re-query for the second click. + const sidebar = await screen.findByRole('complementary', { name: /Filters/ }); + fireEvent.click(within(sidebar).getByRole('button', { name: /Transit/ })); + + // Wait for the toggle-on fetch. + await waitFor(() => { + const last = projectsUrls(fetchSpy).slice(-1)[0] ?? ''; + expect(last).toContain('tag=topic.transit'); + }); + + // Click again in the sidebar — should remove the tag from the URL. + fireEvent.click(within(sidebar).getByRole('button', { name: /Transit/ })); + + await waitFor(() => { + const last = projectsUrls(fetchSpy).slice(-1)[0] ?? ''; + expect(last).not.toContain('tag=topic.transit'); + }); + }); }); diff --git a/plans/project-filters-fix.md b/plans/project-filters-fix.md new file mode 100644 index 0000000..17daebb --- /dev/null +++ b/plans/project-filters-fix.md @@ -0,0 +1,188 @@ +--- +status: done +depends: [] +specs: + - specs/api/projects.md + - specs/screens/projects-index.md +issues: [] +pr: 127 +--- + +# Plan: project filters — fix the data-shape bug, switch to OR-within / AND-across, hoist stage to a row + +## Scope + +User reported that clicking any project filter chip "selects them all" and +toggles the whole tab on/off without changing the result list. Investigation +surfaced a long-standing data-shape bug (since the SPA's first feature drop) +**plus** a few real UX shortcomings worth addressing in the same pass. + +What ships: + +- **API bug → API contract change.** The SPA's `FacetEntry` type expected + `{ handle, slug, title, count }`; the API has always returned + `{ tag, title, count }`. Both ends were misaligned with each other and + with the spec. Align the SPA to the spec. +- **Tag filter semantics: OR within namespace, AND across.** Spec previously + said "AND across repeats" — the more common faceted-search pattern of + OR-within / AND-across produces friendlier discovery. +- **Facet counts: filtered with self-namespace exclusion.** Spec previously + said "unfiltered corpus so counts don't whipsaw." Replaced with the + filtered-with-self-exclusion pattern so counts honestly answer "how many + results would I get if I also picked X." Selected tags get pinned into + their namespace facet so the SPA can render them even when they fall + below the top-10 cut. +- **Stage facet hoisted to a horizontal pill row above results.** Stages + are a 7-value fixed enum; the tabbed sidebar treatment hid them behind + a click. New `StageFilterRow` component sits above the search box. + +## Implements + +- [api/projects.md](../specs/api/projects.md) — tag filter semantics + facet + count semantics rewritten. +- [screens/projects-index.md](../specs/screens/projects-index.md) — sidebar + loses the Stages tab; new "Stage row" section documents the hoist. + +## Approach + +### 1. Spec changes (specops, source of truth first) + +- `specs/api/projects.md` → tag is "OR within namespace, AND across namespaces" + with worked example; facets are filtered-with-self-namespace-exclusion + with pinned-selected behavior documented. +- `specs/screens/projects-index.md` → new "Stage row" section above results; + sidebar tabs reduced to Topics / Tech / Events. + +### 2. API: `apps/api/src/services/project.ts` + +Refactored the `list()` filter pass into a predicate factory that takes +optional exclusions (`excludeStage`, `excludeTagNs`). The main listing +uses the full predicate; each facet group is computed over a project +set built with the right exclusion. Tag handles are grouped by namespace +at request entry; the per-project tag-set check is OR within each +namespace's filter set and AND across namespaces. + +### 3. Facets: `apps/api/src/store/memory/facets.ts` + +`getProjectFacets` (cached, unfiltered) → `computeProjectFacets` (per +request, takes project sets per namespace exclusion). Pins active +selections with `count = 0` when no other project in the set carries +that tag, so the SPA always renders selected chips. `getPeopleFacets` +is unchanged (still cached / unfiltered) — out of scope for this fix; +people-list UX gets the same treatment when someone notices it +matters. + +### 4. SPA: data-shape fix + stage row + +- `apps/web/src/lib/api.ts` — `FacetEntry.handle` → `tag`; drop `slug` + + `namespace` (never on the wire). +- `apps/web/src/components/FacetSidebar.tsx` — read `e.tag`; sort active + selections to the top; drop the Stages tab + props. +- `apps/web/src/components/StageFilterRow.tsx` (new) — horizontal pill row + using `STAGES` metadata. +- `apps/web/src/screens/ProjectsIndex.tsx` — render `StageFilterRow` above + the search box; drop stage props from `FacetSidebar`. +- The other two `FacetSidebar` consumers (`PeopleIndex`, `HelpWantedIndex`) + already passed only the tag-related props, so they're unaffected. + +### 5. Tests + +- **`apps/api/tests/project-filters.test.ts` (new)** — 9 cases drive + `ProjectService` directly against a hand-built `InMemoryState`. Covers: + OR-within-namespace, AND-across-namespaces, the combined case, unknown + handles silently dropped, byTopic widens when topic is filtered, byTech + narrows toward topic, byStage excludes its own filter, selected-tag + pinning with count 0, and `tag` (not `handle`) in the response. +- **`apps/web/tests/ProjectsIndex.test.tsx`** — pre-existing tests + updated to the new facet shape (was using stale `{ handle, slug, … }` + mock that didn't match the API). New cases: sidebar chip renders with + count + is distinct from other chips (would have caught the original + bug); clicking a topic chip lands `tag=topic.transit` in the next + `/api/projects` fetch URL (would have caught the empty-slug `tag=topic.` + symptom); stage pill in the row above results adds to `stageIn=`; + toggle-twice flips the chip off. +- **`apps/api/tests/read-api.test.ts`** — the existing "filters by tag + AND facets still reflect unfiltered corpus" test updated to match the + new spec; the data check (filtered list contains the project) still + holds, the facet check is reframed as "selected tag pinned/included + in its namespace." + +## Validation + +- [x] Spec updated: tag OR-within / AND-across + facet self-exclusion + + stage row. +- [x] `ProjectService.list` honors OR-within-namespace and AND-across-namespaces. +- [x] `computeProjectFacets` returns filtered counts per the spec, pins + selected tags below top-10. +- [x] SPA `FacetEntry` aligned with API; `FacetSidebar` reads `e.tag`. +- [x] `StageFilterRow` renders above results with stage filter state. +- [x] New API tests: 9/9. New SPA tests: 7/7 in ProjectsIndex. +- [x] Full sweeps: api 397/397, web 73/73, shared 75/75. +- [x] `npm run type-check && npm run lint` clean. + +## Risks / unknowns + +- **Behavior change on a public endpoint.** External consumers of + `/api/projects?tag=...` who relied on AND-across-repeats semantics + would observe a different result. Acceptable: the spec change is + documented; in practice the only consumer is our own SPA, which was + already broken on this code path. +- **Facet computation is now per-request.** Before: a global cache + invalidated on writes. After: O(projects × tags) per `/api/projects` + request. Civic-scale corpus (~500 projects, ~5K tag assignments) + makes this trivially fast (~ms) — no caching needed for v1. Worth + noting if the corpus grows by 10x. +- **PeopleIndex still uses the OLD unfiltered/cached facet path.** Same + data-shape fix benefits it (the SPA bug was uniform); the OR-within / + filtered-counts treatment isn't applied. Followup if/when noticed. + +## Notes + +Shipped clean — all 9 new API tests + 7 SPA tests pass on the first sweep; +full sweeps (api 397, web 73, shared 75) clean; lint + type-check green. + +Surprises: + +- **The data-shape bug had been live since the SPA's first feature drop** + (commits `2f0a62c` and `427c2bf`), not a recent regression. The `FacetEntry` + type was written against a proposed API shape that never matched what the + backend actually shipped. No test exercised the click → URL → fresh-fetch + path against real-shaped data, so the bug sat dormant until a user clicked + a chip. Lesson worth memorializing: tests against handcrafted mock data + must match the spec's wire shape, not the SPA's TypeScript types — types + describe what the SPA *wants*, the spec describes what it *gets*. +- **Per-request facet computation is fast enough at civic scale.** The + pre-fix `facets.ts` cached a global facet object invalidated on every + write. The new per-request path computes 5 separate filter passes + (the main listing + 4 facet exclusions) over the project set. At + ~500 projects + ~5K tag assignments this is ~1-2ms total — well below + the existing route's overhead. Caching isn't worth the complexity. +- **Selected-tag pinning matters more than I expected.** Without it, a + user picks `topic.education` (one project tagged), then also picks + `tech.python` (two projects, but no overlap with education) — the + education facet would disappear from the sidebar because count=0, and + the user wouldn't even know it's still applied. Pinning keeps the + selection visible at count=0. +- **OR-within / AND-across is also the right default for `?tag=` API + consumers**, not just the SPA. Anyone using the public API directly + who wants strict-AND semantics can still get it by issuing one tag + per request — but the natural URL `?tag=a&tag=b` now matches the + user expectation across pretty much every faceted-search product + shipped in the last decade. + +## Follow-ups + +- **People + Help-Wanted indexes have the same UX bug.** The SPA fix to + `FacetEntry` benefits all three sidebars uniformly (the click → URL + path now produces correct handles everywhere). But the OR-within / + filtered-counts treatment is still projects-only on the backend. + *Tracked as* — follow-up when someone notices the people-list facets + feel off, or as part of a broader tag-search audit. +- **Facet caching at scale.** If the project corpus crosses ~10K, the + per-request facet computation will start to show up in route timing. + *None* — civic-scale is fine; revisit when the data shows the need. +- **Spec drift auditor coverage.** The original mismatch (SPA's + `FacetEntry` vs the spec) would have been caught by an auditor pass + that compared TS types in `apps/web/src/lib/api.ts` against the JSON + example blocks in `specs/api/*.md`. *None* for now — manual review on + the next audit pass. diff --git a/specs/api/projects.md b/specs/api/projects.md index 8fee2ac..f0ffb13 100644 --- a/specs/api/projects.md +++ b/specs/api/projects.md @@ -25,7 +25,7 @@ See [data-model.md](../data-model.md#project) for the entity shape, [behaviors/p | `q` | string | Free-text search across `title`, `summary`, `overview` (SQLite FTS5 in-memory index — see [behaviors/storage.md](../behaviors/storage.md#full-text-search)). | | `stage` | enum | `commenting` \| `bootstrapping` \| `prototyping` \| `testing` \| `maintaining` \| `drifting` \| `hibernating` | | `stageIn` | csv-of-enum | Multi-value filter. | -| `tag` | string | Tag handle in `.` form (e.g., `tech.flutter`). Repeatable; semantics are AND across repeats. | +| `tag` | string | Tag handle in `.` form (e.g., `tech.flutter`). Repeatable; **OR within namespace, AND across namespaces.** `?tag=topic.transit&tag=topic.mapping&tag=tech.python` returns projects that are tagged `(topic.transit OR topic.mapping) AND tech.python`. Matches the conventional faceted-search behavior — within a facet group users widen, across groups they narrow. | | `maintainer` | string | Person slug. | | `memberSlug` | string | Person slug — returns projects where this person is in `project-memberships`. | | `helpWanted` | bool | If `true`, only projects with at least one `helpWantedRoles.status = 'open'`. | @@ -53,7 +53,16 @@ Soft-deleted projects (`deletedAt is not null`) are excluded for non-staff and i } ``` -The `facets` object replaces laddr's `projectsTags.{byTopic,byTech,byEvent}` and `projectsStages`. Facets reflect the **unfiltered** corpus so the sidebar counts don't whipsaw when a filter is applied. Top 10 per facet group; full list via [api/tags.md](tags.md). +The `facets` object replaces laddr's `projectsTags.{byTopic,byTech,byEvent}` and `projectsStages`. Top 10 per facet group; full list via [api/tags.md](tags.md). + +**Counts reflect the filtered corpus with self-namespace exclusion**: + +- For `byTopic`, `byTech`, `byEvent` — count over the project set filtered by every active criterion *except* tag filters in that same namespace. So if the user has selected `topic.transit`, the topic facet still shows other topics (with counts narrowed by the rest of the filters) so they can widen their topic selection without losing track. Selecting another topic adds to the topic set (OR within namespace). +- For `byStage` — same pattern, count over the set filtered by everything *except* `stage` / `stageIn`. + +Active selections that fall below the top 10 in their facet group are **pinned** into the response so the SPA can render them as still-selected. Counts on the pinned entries reflect the same self-exclusion rule. + +Counts are honest about the search the user is composing — "if you click this, this is how many projects you'd see" for OR-widening within the group, and a stable reference for what's still available across the rest of the corpus. ### ProjectListItem shape diff --git a/specs/screens/projects-index.md b/specs/screens/projects-index.md index d5c2d18..4220857 100644 --- a/specs/screens/projects-index.md +++ b/specs/screens/projects-index.md @@ -31,18 +31,19 @@ Filters are reflected in the query string. Sharable, back/forward-friendly. - Right side: "Add Project" button — visible to signed-in users (matches laddr precedent where any user could add a project) - Subhead paragraph and intro content from a static `projects-browse-introduction` content block; v1 hard-codes the current copy (see laddr `html-templates/projects/projects.tpl` line 24 reference) -### Sidebar (left, ≥ sm) +### Stage row (top, above results) + +Stages are a small fixed enum (7 values) and feel different from open-ended tag taxonomies. They render as a **horizontal pill row** spanning the full results column, above the search box. Each pill shows the stage label, count, and the stage's accent color (from [behaviors/project-stages.md](../behaviors/project-stages.md)). Multi-select: clicking adds the stage to the filter; clicking an active pill removes it. Order matches the `STAGE_ORDER` constant (commenting → hibernating). -Tab strip across the top of the sidebar: **Topics / Tech / Events / Stages** — default Topics. +### Sidebar (left, ≥ sm) -For each tab: +Tab strip across the top of the sidebar: **Topics / Tech / Events** — default Topics. -- **Topics, Tech, Events** — list of tag chips with item count. Each links to the same projects page with that tag added. Top 10 by count (from `metadata.facets`). "See all →" links to `/tags?namespace=topic` (etc). -- **Stages** — list of all 7 stages with count and color matching [behaviors/project-stages.md](../behaviors/project-stages.md). Clicking adds that stage to the `stage` filter. +For each tab: list of tag chips with item count. Each click toggles that tag in the URL state (OR-within-namespace per [api/projects.md](../api/projects.md)). Top 10 by count (from `metadata.facets`). Active selections always appear first, with a "selected" visual treatment (filled vs. outlined). "See all →" links to `/tags?namespace=topic` (etc). -Multi-select: clicking an already-active filter chip removes it. Active filters are visually distinguished (filled vs outlined). +Counts come from `metadata.facets` and reflect the filtered corpus with self-namespace exclusion — selecting `topic.transit` does not collapse the topic list to itself; the other topic counts simply re-rank to "what you'd get if you also picked this." Counts in `byTech`/`byEvent` narrow toward the full filter state. -Active filters surface as removable chips below the H1, "Filters: [Tech: Flutter ×] [Stage: Prototyping ×] Clear all". +Active filters surface as removable chips below the H1, "Filters: [Tech: Flutter ×] [Topic: Transit ×] Clear all". Stage selections also appear here when active (in addition to the row above), so a quick `Clear all` is always within reach. ### Sort control