Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/api/src/services/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type EventResponse,
type EventRsvpResponse,
type EventSummaryResponse,
type ProjectRef,
} from './serializers/event.js';

export type EventWhen = 'upcoming' | 'past' | 'all';
Expand Down Expand Up @@ -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<string>();
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<string>();
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<string>();
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/services/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,12 @@ export class ProjectService {
// ---- Resolve all filter inputs ----

let ftsSlugs: Set<string> | null = null;
/** Relevance rank per slug — the order a search without an explicit sort keeps. */
let ftsRank: Map<string, number> | 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;
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/services/serializers/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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),
Expand All @@ -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,
};
}
Expand Down
17 changes: 12 additions & 5 deletions apps/api/src/store/fts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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`,
),

Expand All @@ -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`,
),

Expand All @@ -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`,
),
};
Expand Down Expand Up @@ -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(' ');
}
11 changes: 11 additions & 0 deletions apps/api/tests/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
19 changes: 19 additions & 0 deletions apps/api/tests/fts-query.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
20 changes: 20 additions & 0 deletions apps/api/tests/project-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
175 changes: 175 additions & 0 deletions apps/web/src/components/ProjectPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<T>(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<HTMLInputElement>) => {
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 (
<div className="relative w-full max-w-xs">
<Input
role="combobox"
aria-label={ariaLabel}
aria-expanded={expanded}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={expanded && items.length > 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 && (
<div className="absolute z-20 mt-1 w-full rounded-md border border-border bg-popover py-1 text-sm shadow-md">
{status ? (
<p className="px-3 py-1.5 text-muted-foreground" role="status">
{status}
</p>
) : (
<ul id={listId} role="listbox" aria-label={ariaLabel} className="max-h-72 overflow-auto">
{shown.map((group) => (
<li key={group.label} role="presentation">
{group.label && (
<p className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">{group.label}</p>
)}
<ul role="group" aria-label={group.label || undefined}>
{group.items.map((p) => {
const i = ++index;
return (
<li
key={p.slug}
id={`${listId}-${i}`}
role="option"
aria-selected={i === activeIndex}
className={cn('cursor-pointer px-3 py-1.5', i === activeIndex && 'bg-accent text-accent-foreground')}
// mousedown, not click, so the input's blur doesn't close the list first
onMouseDown={(e) => {
e.preventDefault();
choose(p);
}}
onMouseEnter={() => setActive(i)}
>
{p.title}
</li>
);
})}
</ul>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
Loading
Loading