diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index 3147d96..6ffaddb 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -1012,9 +1012,35 @@ function bySoonest(a: PlayerEvent, b: PlayerEvent): number { return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId } +/** + * Best attended first — the count of players who answered Going (`AttendeeCount`, the + * number the client shows), which is exactly what the sort is meant to rank. Interested + * and Can't-go don't count, as they don't toward the displayed count either. Ties fall + * back to soonest first so the order stays stable across pages. + */ +function byAttendance(a: PlayerEvent, b: PlayerEvent): number { + return b.AttendeeCount - a.AttendeeCount || bySoonest(a, b) +} + +/** + * The orders the search serves, by the `sort` the client sends: `StartTime` (soonest + * first, the default) and `Attendance` (most Going replies first). + */ +export type EventSort = 'StartTime' | 'Attendance' + +/** + * Reads the `sort` query into an {@link EventSort}, case-insensitively. Anything + * unrecognised — or nothing at all — is the default order, soonest first, rather than + * a 400: the browse screen has always sent this param and been served regardless. + */ +export function parseEventSort(raw: string | undefined): EventSort { + return raw?.toLowerCase() === 'attendance' ? 'Attendance' : 'StartTime' +} + /** * Event search — the browse query on the player-events screen. Term by term, an empty - * query browsing everything upcoming; paginated via skip/take, soonest first. + * query browsing everything upcoming; paginated via skip/take, in the order `sort` + * names ({@link EventSort}): soonest first by default, or most Going replies first. * * A term is matched one of two ways, and the `#` decides which: * @@ -1035,7 +1061,8 @@ export async function searchEvents( db: D1Database, query: string, skip: number, - take: number + take: number, + sort: EventSort = 'StartTime' ): Promise { const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean) // A `#` prefix makes a term a tag; the rest are matched against the text. A bare `#` @@ -1067,5 +1094,5 @@ export async function searchEvents( ) } - return events.sort(bySoonest).slice(skip, skip + take) + return events.sort(sort === 'Attendance' ? byAttendance : bySoonest).slice(skip, skip + take) } diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 78cefbe..0253fd1 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -26,6 +26,7 @@ import { inviteToEvent, isEventResponseType, parseEventBody, + parseEventSort, parseEventTags, parseEventTime, searchEvents, @@ -600,14 +601,15 @@ export const eventRoutes = new Hono({ strict: false }) 'match and the two kinds combine, so `#workshops trigonometry` is the ' + 'workshops-tagged events whose text also mentions trigonometry.\n\n' + 'Events that have already finished are left out — a name match on something that ' + - 'ended last month is noise on a browse screen. Soonest first, paginated via ' + + 'ended last month is noise on a browse screen. Ordered by `sort` and paginated via ' + 'skip/take. A bare array.', parameters: [ stringQuery('query', 'Search terms; `#tag` matches a tag, anything else the text'), stringQuery( 'sort', - 'Accepted and echoed by the client as `StartTime`, which is the only order ' + - 'served (soonest first); any other value sorts the same way' + '`StartTime` (soonest first, the default) or `Attendance` (most Going replies ' + + 'first — the event’s `AttendeeCount` — ties soonest first). Case-insensitive; ' + + 'any other value is served as `StartTime`' ), ...pageParams(50), ], @@ -616,7 +618,15 @@ export const eventRoutes = new Hono({ strict: false }) async (c) => { const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50 - return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take)) + return c.json( + await searchEvents( + c.env.DB, + c.req.query('query') ?? '', + skip, + take, + parseEventSort(c.req.query('sort')) + ) + ) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index ca06d98..e6c8785 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -8677,6 +8677,71 @@ describe('player events', () => { expect(await search('?skip=1&take=1')).toEqual([all[1]]) }) + test('GET /api/playerevents/v1/search?sort=Attendance ranks by Going replies', async () => { + const search = async (qs: string): Promise => + (await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[] + const respond = (id: number, type: number, sub: string): Promise => + post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub) + + // Three events sharing a searchable word, created soonest-first so the default + // order is the OPPOSITE of the attendance order — the sort has to do something. + const quiet = await create({ RoomId: 3, Name: 'Sortable Quiet', StartTime: at(2 * HOUR) }) + const busy = await create({ RoomId: 3, Name: 'Sortable Busy', StartTime: at(3 * HOUR) }) + const packed = await create({ RoomId: 3, Name: 'Sortable Packed', StartTime: at(4 * HOUR) }) + // Every creator is Going from create (1 each). `busy` gains one more Going; `packed` + // gains two. Interested and Can't-go on `quiet` don't move it: only a Yes counts. + for (const [id, type, sub] of [ + [busy.PlayerEventId, 0, '43'], + [packed.PlayerEventId, 0, '43'], + [packed.PlayerEventId, 0, '44'], + [quiet.PlayerEventId, 1, '43'], + [quiet.PlayerEventId, 1, '44'], + [quiet.PlayerEventId, 2, '45'], + ] as const) { + expect((await respond(id, type, sub)).status).toBe(200) + } + + // Default and `StartTime`: soonest first. + const soonest = [quiet.PlayerEventId, busy.PlayerEventId, packed.PlayerEventId] + expect((await search('?query=sortable')).map((e) => e.PlayerEventId)).toEqual(soonest) + expect((await search('?query=sortable&sort=StartTime')).map((e) => e.PlayerEventId)).toEqual( + soonest + ) + + // `Attendance`: most Going first (3, 2, 1), case-insensitively. + const byAttendance = await search('?query=sortable&sort=Attendance') + expect(byAttendance.map((e) => e.PlayerEventId)).toEqual([ + packed.PlayerEventId, + busy.PlayerEventId, + quiet.PlayerEventId, + ]) + expect(byAttendance.map((e) => e.AttendeeCount)).toEqual([3, 2, 1]) + expect((await search('?query=sortable&sort=attendance')).map((e) => e.PlayerEventId)).toEqual( + byAttendance.map((e) => e.PlayerEventId) + ) + + // A tie in attendance falls back to soonest first: another 1-Going event, later + // than `quiet`, lands after it. + const late = await create({ RoomId: 3, Name: 'Sortable Late', StartTime: at(5 * HOUR) }) + expect((await search('?query=sortable&sort=Attendance')).map((e) => e.PlayerEventId)).toEqual([ + packed.PlayerEventId, + busy.PlayerEventId, + quiet.PlayerEventId, + late.PlayerEventId, + ]) + + // Paging walks the sorted order, and an unknown sort is the default, not a 400. + expect(await search('?query=sortable&sort=Attendance&skip=1&take=1')).toEqual([ + byAttendance[1], + ]) + const unknown = await get('/api/playerevents/v1/search?query=sortable&sort=Popularity') + expect(unknown.status).toBe(200) + expect(((await unknown.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)).toEqual([ + ...soonest, + late.PlayerEventId, + ]) + }) + test('GET /api/playerevents/v1 serves the browse feed as listings', async () => { const res = await get('/api/playerevents/v1') expect(res.status).toBe(200)