diff --git a/apps/api/src/services/event.ts b/apps/api/src/services/event.ts index 8e81a9a..041f097 100644 --- a/apps/api/src/services/event.ts +++ b/apps/api/src/services/event.ts @@ -17,6 +17,7 @@ import { type EventResponse, type EventRsvpResponse, type EventSummaryResponse, + type ProjectRef, } from './serializers/event.js'; export type EventWhen = 'upcoming' | 'past' | 'all'; @@ -78,6 +79,30 @@ export class EventService { return id ? (this.#state.eventRsvps.get(id) ?? null) : null; } + /** + * Projects the caller named on their RSVPs to other events, most recently + * answered first, de-duplicated, at most eight — the follow-up picker's + * first suggestions (specs/api/events.md#get-apieventsslug). + */ + viewerRecentProjects(eventId: string, caller: CallerSession | undefined): ProjectRef[] { + if (!caller) return []; + const ids = this.#state.rsvpsByPerson.get(caller.id) ?? new Set(); + const named = [...ids] + .map((id) => this.#state.eventRsvps.get(id)) + .filter((r): r is EventRsvp => r !== undefined && r.eventId !== eventId && r.plan === 'project' && !!r.projectId) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const out: ProjectRef[] = []; + const seen = new Set(); + for (const r of named) { + const p = this.#state.projects.get(r.projectId!); + if (!p || p.deletedAt || seen.has(p.id)) continue; + seen.add(p.id); + out.push({ slug: p.slug, title: p.title }); + if (out.length === 8) break; + } + return out; + } + /** Going RSVPs for an event, oldest first. */ rsvpsFor(eventId: string): EventRsvp[] { const ids = this.#state.rsvpsByEvent.get(eventId) ?? new Set(); @@ -114,6 +139,7 @@ export class EventService { return serializeEvent(event, { rsvpCount, viewerRsvp: this.viewerRsvp(event.id, caller), + viewerRecentProjects: this.viewerRecentProjects(event.id, caller), projectLookup: (id) => this.#state.projects.get(id), series: this.seriesRef(event), permissions: computeEventPermissions(caller, event, rsvpCount, now), diff --git a/apps/api/src/services/project.ts b/apps/api/src/services/project.ts index 2d75ea7..2c63275 100644 --- a/apps/api/src/services/project.ts +++ b/apps/api/src/services/project.ts @@ -84,9 +84,12 @@ export class ProjectService { // ---- Resolve all filter inputs ---- let ftsSlugs: Set | null = null; + /** Relevance rank per slug — the order a search without an explicit sort keeps. */ + let ftsRank: Map | null = null; if (opts.q) { const slugs = this.#fts.searchProjects(opts.q); ftsSlugs = new Set(slugs); + if (!opts.sort) ftsRank = new Map(slugs.map((slug, i) => [slug, i])); } let memberPersonId: string | undefined; @@ -221,7 +224,12 @@ export class ProjectService { // ---- Main listing ---- const filtered = allProjects.filter(buildMatcher()); - filtered.sort((a, b) => compareProjects(a, b, sortSpec)); + // A search with no explicit sort lists by relevance (specs/api/projects.md); + // otherwise by the sort spec (default -updatedAt). + const rank = ftsRank; + filtered.sort((a, b) => + rank ? (rank.get(a.slug) ?? Infinity) - (rank.get(b.slug) ?? Infinity) : compareProjects(a, b, sortSpec), + ); const totalItems = filtered.length; const page = Math.max(1, opts.page ?? 1); diff --git a/apps/api/src/services/serializers/event.ts b/apps/api/src/services/serializers/event.ts index 98f2381..91d1bce 100644 --- a/apps/api/src/services/serializers/event.ts +++ b/apps/api/src/services/serializers/event.ts @@ -53,6 +53,8 @@ export interface EventSummaryResponse { } export interface EventResponse extends EventSummaryResponse { + /** Projects the caller named on RSVPs to other events, newest first (specs/api/events.md). */ + readonly viewerRecentProjects: ProjectRef[]; readonly description: string | null; readonly descriptionHtml: string; readonly agenda: EventAgendaItemResponse[]; @@ -130,7 +132,7 @@ function serializeAgendaItem(item: EventAgendaItem): EventAgendaItemResponse { export function serializeEvent( event: Event, - opts: SerializeEventOptions & { readonly permissions: EventPermissions }, + opts: SerializeEventOptions & { readonly permissions: EventPermissions; readonly viewerRecentProjects: ProjectRef[] }, ): EventResponse { return { ...serializeEventSummary(event, opts), @@ -139,6 +141,7 @@ export function serializeEvent( descriptionHtml: event.description ? renderMarkdown(event.description).html : '', agenda: (event.agenda ?? []).map(serializeAgendaItem), cancellationNote: event.cancellationNote ?? null, + viewerRecentProjects: opts.viewerRecentProjects, permissions: opts.permissions, }; } diff --git a/apps/api/src/store/fts.ts b/apps/api/src/store/fts.ts index d3abc40..ed1686e 100644 --- a/apps/api/src/store/fts.ts +++ b/apps/api/src/store/fts.ts @@ -41,6 +41,9 @@ export function buildFtsEngine(state: InMemoryState): FtsEngine { const db = new Database(':memory:'); db.exec(` + -- Ranking: bm25() with per-column weights (title 10, summary 3, body 1), + -- so a word in the name outranks the same word in the text — the + -- order a find-as-you-type box wants (specs/behaviors/storage.md). CREATE VIRTUAL TABLE projects_fts USING fts5(slug UNINDEXED, title, summary, overview, tokenize='porter ascii'); @@ -61,7 +64,7 @@ export function buildFtsEngine(state: InMemoryState): FtsEngine { searchProjects: db.prepare( `SELECT slug FROM projects_fts WHERE projects_fts MATCH ? - ORDER BY rank + ORDER BY bm25(projects_fts, 0, 10, 3, 1) LIMIT 1000`, ), @@ -74,7 +77,7 @@ export function buildFtsEngine(state: InMemoryState): FtsEngine { searchPeople: db.prepare( `SELECT slug FROM people_fts WHERE people_fts MATCH ? - ORDER BY rank + ORDER BY bm25(people_fts, 0, 10, 1) LIMIT 1000`, ), @@ -87,7 +90,7 @@ export function buildFtsEngine(state: InMemoryState): FtsEngine { searchHelpWanted: db.prepare( `SELECT id FROM help_wanted_fts WHERE help_wanted_fts MATCH ? - ORDER BY rank + ORDER BY bm25(help_wanted_fts, 0, 10, 1) LIMIT 1000`, ), }; @@ -200,13 +203,17 @@ export function buildFtsEngine(state: InMemoryState): FtsEngine { /** * Sanitize a user query string so it doesn't cause SQLite FTS5 syntax errors. - * Wraps each word in double-quotes to treat them as exact phrases, then ANDs them. + * Wraps each word in double-quotes to treat them as exact phrases, then ANDs + * them. The last word matches as a prefix — the query is what someone has + * typed so far, so `hack` finds "Hackathon" and `civic eng` finds "Civic + * Engagement" (specs/behaviors/storage.md#full-text-search). */ -function sanitizeFtsQuery(q: string): string { +export function sanitizeFtsQuery(q: string): string { const words = q .trim() .split(/\s+/) .filter(Boolean) .map((w) => `"${w.replace(/"/g, '""')}"`); + if (words.length > 0) words[words.length - 1] += '*'; return words.join(' '); } diff --git a/apps/api/tests/events.test.ts b/apps/api/tests/events.test.ts index 646b6e5..6b0d7ba 100644 --- a/apps/api/tests/events.test.ts +++ b/apps/api/tests/events.test.ts @@ -313,6 +313,17 @@ describe('RSVP', () => { expect(withProject.statusCode).toBe(201); expect(withProject.json().data.rsvp).toMatchObject({ plan: 'project', project: { slug: fixtures.projectSlug } }); + // The project Jane named now leads her picker on *other* events + // (viewerRecentProjects, specs/api/events.md); not on this one, and + // never for anonymous callers. + const other = await createEvent(hackNight(14)); + const otherDetail = await app!.inject({ method: 'GET', url: `/api/events/${other.slug}`, headers: { cookie: jane } }); + expect(otherDetail.json().data.viewerRecentProjects).toEqual([{ slug: fixtures.projectSlug, title: expect.any(String) }]); + const sameDetail = await app!.inject({ method: 'GET', url: `/api/events/${slug}`, headers: { cookie: jane } }); + expect(sameDetail.json().data.viewerRecentProjects).toEqual([]); + const anonDetail = await app!.inject({ method: 'GET', url: `/api/events/${other.slug}` }); + expect(anonDetail.json().data.viewerRecentProjects).toEqual([]); + const noSlug = await app!.inject({ method: 'PUT', url: `/api/events/${slug}/rsvp`, diff --git a/apps/api/tests/fts-query.test.ts b/apps/api/tests/fts-query.test.ts new file mode 100644 index 0000000..2da0d7d --- /dev/null +++ b/apps/api/tests/fts-query.test.ts @@ -0,0 +1,19 @@ +/** + * Full-text query shaping — specs/behaviors/storage.md#full-text-search: + * every word must match, the last word matches as a prefix. + */ +import { describe, expect, it } from 'vitest'; + +import { sanitizeFtsQuery } from '../src/store/fts.js'; + +describe('sanitizeFtsQuery', () => { + it('quotes each word and makes the last one a prefix', () => { + expect(sanitizeFtsQuery('civic eng')).toBe('"civic" "eng"*'); + expect(sanitizeFtsQuery('hack')).toBe('"hack"*'); + }); + + it('trims, collapses whitespace, and escapes quotes', () => { + expect(sanitizeFtsQuery(' say "hi" there ')).toBe('"say" """hi""" "there"*'); + expect(sanitizeFtsQuery(' ')).toBe(''); + }); +}); diff --git a/apps/api/tests/project-filters.test.ts b/apps/api/tests/project-filters.test.ts index 936cc69..03ef033 100644 --- a/apps/api/tests/project-filters.test.ts +++ b/apps/api/tests/project-filters.test.ts @@ -77,6 +77,26 @@ const noopFts: FtsEngine = { searchBlogPosts: () => [], }; +describe('ProjectService search order', () => { + it('a query with no explicit sort lists in relevance order; an explicit sort wins', () => { + const { state } = setupState(); + const all = [...state.projects.values()].filter((p) => !p.deletedAt); + const byRelevance = all.map((p) => p.slug).reverse(); + const fts: FtsEngine = { ...noopFts, searchProjects: () => byRelevance }; + const service = new ProjectService(state, fts); + + const relevance = service.list({ q: 'anything' }); + if ('error' in relevance) throw new Error(relevance.error); + expect(relevance.items.map((p) => p.slug)).toEqual(byRelevance); + + const titled = service.list({ q: 'anything', sort: 'title' }); + if ('error' in titled) throw new Error(titled.error); + expect(titled.items.map((p) => p.slug)).toEqual( + [...all].sort((a, b) => a.title.localeCompare(b.title)).map((p) => p.slug), + ); + }); +}); + function setupState(): { state: InMemoryState; service: ProjectService; diff --git a/apps/web/src/components/ProjectPicker.tsx b/apps/web/src/components/ProjectPicker.tsx new file mode 100644 index 0000000..a66b8aa --- /dev/null +++ b/apps/web/src/components/ProjectPicker.tsx @@ -0,0 +1,175 @@ +import { useEffect, useId, useState, type KeyboardEvent } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Input } from '@/components/ui/input'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +export interface ProjectChoice { + readonly slug: string; + readonly title: string; +} + +export interface ProjectSuggestionGroup { + readonly label: string; + readonly items: readonly ProjectChoice[]; +} + +interface ProjectPickerProps { + /** Shown when the box is opened with nothing typed — e.g. the viewer's recent projects, then teams other attendees named. */ + groups: readonly ProjectSuggestionGroup[]; + onPick: (project: ProjectChoice) => void; + disabled?: boolean; + placeholder?: string; + 'aria-label'?: string; +} + +const MAX_RESULTS = 8; +const DEBOUNCE_MS = 200; + +/** `value`, settled for `ms` after its last change. */ +function useDebounced(value: T, ms: number): T { + const [settled, setSettled] = useState(value); + useEffect(() => { + const t = setTimeout(() => setSettled(value), ms); + return () => clearTimeout(t); + }, [value, ms]); + return settled; +} + +/** + * Find-as-you-type project combobox (screens/event-detail.md → follow-up). + * Never lists the catalog: opened empty it shows the suggestion `groups` + * (empty groups omitted); once text is typed it searches + * `GET /api/projects?q=` (last word as a prefix) and lists at most eight + * results in relevance order. + */ +export function ProjectPicker({ + groups, + onPick, + disabled, + placeholder = 'Type a project name', + 'aria-label': ariaLabel = 'Pick a project', +}: ProjectPickerProps) { + const id = useId(); + const listId = `${id}-list`; + const [text, setText] = useState(''); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + + const needle = text.trim(); + const q = useDebounced(needle, DEBOUNCE_MS); + const searchQ = useQuery({ + queryKey: ['projects', { q, perPage: MAX_RESULTS }], + queryFn: () => api.projects.list({ q, perPage: MAX_RESULTS }), + enabled: q.length > 0, + staleTime: 60_000, + }); + + const searching = needle.length > 0; + const shown: readonly ProjectSuggestionGroup[] = searching + ? [{ label: '', items: (searchQ.data?.data ?? []).slice(0, MAX_RESULTS) }] + : groups.filter((g) => g.items.length > 0).map((g) => ({ label: g.label, items: g.items.slice(0, MAX_RESULTS) })); + // One flat list for keyboard navigation across groups. + const items: readonly ProjectChoice[] = shown.flatMap((g) => g.items); + const waiting = searching && (q !== needle || searchQ.isLoading); + // Nothing typed and nothing to suggest: the input's placeholder is the + // whole hint; an empty popover would only repeat it. + const expanded = open && (searching || items.length > 0); + const activeIndex = Math.min(active, Math.max(items.length - 1, 0)); + + const choose = (p: ProjectChoice) => { + setText(p.title); + setOpen(false); + onPick(p); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!open) setOpen(true); + else setActive((a) => Math.min(a + 1, items.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive((a) => Math.max(a - 1, 0)); + } else if (e.key === 'Enter' && expanded) { + e.preventDefault(); + const p = items[activeIndex]; + if (p) choose(p); + } else if (e.key === 'Escape' && open) { + e.preventDefault(); + setOpen(false); + } + }; + + let status: string | null = null; + if (searching && waiting) status = 'Searching…'; + else if (searching && items.length === 0) status = 'No projects match'; + + let index = -1; + return ( +
+ 0 ? `${listId}-${activeIndex}` : undefined} + autoComplete="off" + autoFocus + disabled={disabled} + placeholder={placeholder} + value={text} + onChange={(e) => { + setText(e.target.value); + setOpen(true); + setActive(0); + }} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + onKeyDown={onKeyDown} + /> + {expanded && ( +
+ {status ? ( +

+ {status} +

+ ) : ( +
    + {shown.map((group) => ( +
  • + {group.label && ( +

    {group.label}

    + )} +
      + {group.items.map((p) => { + const i = ++index; + return ( +
    • { + e.preventDefault(); + choose(p); + }} + onMouseEnter={() => setActive(i)} + > + {p.title} +
    • + ); + })} +
    +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f4117bd..e9ea8d7 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -396,6 +396,8 @@ export interface EventSummary { /** The full `Event` shape (named EventDetail to avoid the DOM `Event` global). */ export interface EventDetail extends EventSummary { + /** Projects the viewer named on RSVPs to other events, newest first; `[]` when anonymous. */ + readonly viewerRecentProjects: ReadonlyArray<{ readonly slug: string; readonly title: string }>; readonly description: string | null; readonly descriptionHtml: string; readonly cancellationNote: string | null; diff --git a/apps/web/src/screens/EventDetail.tsx b/apps/web/src/screens/EventDetail.tsx index 35bf8c2..44a49e0 100644 --- a/apps/web/src/screens/EventDetail.tsx +++ b/apps/web/src/screens/EventDetail.tsx @@ -17,12 +17,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Breadcrumbs } from '@/components/Breadcrumbs'; import { MarkdownView } from '@/components/MarkdownView'; import { PersonAvatar } from '@/components/PersonAvatar'; +import { ProjectPicker, type ProjectChoice, type ProjectSuggestionGroup } from '@/components/ProjectPicker'; import { ScheduleBlock } from '@/components/ScheduleBlock'; import { useAuth } from '@/hooks/useAuth'; import { api, ApiError, type EventDetail as EventDetailData, + type EventRsvp, type EventRsvpInput, type EventRsvpPlan, type SuccessEnvelope, @@ -49,20 +51,42 @@ const PLAN_LABELS: Record, string> = { interface FollowUpProps { plan: EventRsvpPlan | null; project: { slug: string; title: string } | null; + groups: readonly ProjectSuggestionGroup[]; busy: boolean; onSave: (input: EventRsvpInput) => Promise; } -function FollowUp({ plan, project, busy, onSave }: FollowUpProps) { +/** + * The picker's opened-but-empty suggestions (screens/event-detail.md): the + * viewer's own recent projects from other events first, then the projects + * other attendees of this event named, newest answer first; a project + * appears once, in the first group it belongs to. + */ +export function suggestionGroups( + recent: readonly ProjectChoice[], + rsvps: readonly EventRsvp[], + viewerSlug: string | null | undefined, +): ProjectSuggestionGroup[] { + const seen = new Set(); + const take = (list: readonly ProjectChoice[]): ProjectChoice[] => + list.filter((p) => (seen.has(p.slug) ? false : (seen.add(p.slug), true))); + const mine = take(recent); + const teams = take( + [...rsvps] + .filter((r) => r.project && r.person?.slug !== viewerSlug) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .map((r) => ({ slug: r.project!.slug, title: r.project!.title })), + ); + return [ + { label: 'Your recent projects', items: mine }, + { label: 'Teams already coming', items: teams }, + ].filter((g) => g.items.length > 0); +} + +function FollowUp({ plan, project, groups, busy, onSave }: FollowUpProps) { const [changing, setChanging] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); - const projectsQ = useQuery({ - queryKey: ['projects', { perPage: 100, sort: 'title' }], - queryFn: () => api.projects.list({ perPage: 100, sort: 'title' }), - enabled: pickerOpen, - }); - const save = async (input: EventRsvpInput) => { await onSave(input); setChanging(false); @@ -100,26 +124,14 @@ function FollowUp({ plan, project, busy, onSave }: FollowUpProps) { {PLAN_LABELS.no_project} {pickerOpen ? ( -