mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[events] sort by attendance
This commit is contained in:
@@ -1012,9 +1012,35 @@ function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
|
|||||||
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
|
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
|
* 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:
|
* A term is matched one of two ways, and the `#` decides which:
|
||||||
*
|
*
|
||||||
@@ -1035,7 +1061,8 @@ export async function searchEvents(
|
|||||||
db: D1Database,
|
db: D1Database,
|
||||||
query: string,
|
query: string,
|
||||||
skip: number,
|
skip: number,
|
||||||
take: number
|
take: number,
|
||||||
|
sort: EventSort = 'StartTime'
|
||||||
): Promise<PlayerEvent[]> {
|
): Promise<PlayerEvent[]> {
|
||||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
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 `#`
|
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
inviteToEvent,
|
inviteToEvent,
|
||||||
isEventResponseType,
|
isEventResponseType,
|
||||||
parseEventBody,
|
parseEventBody,
|
||||||
|
parseEventSort,
|
||||||
parseEventTags,
|
parseEventTags,
|
||||||
parseEventTime,
|
parseEventTime,
|
||||||
searchEvents,
|
searchEvents,
|
||||||
@@ -600,14 +601,15 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
|||||||
'match and the two kinds combine, so `#workshops trigonometry` is the ' +
|
'match and the two kinds combine, so `#workshops trigonometry` is the ' +
|
||||||
'workshops-tagged events whose text also mentions trigonometry.\n\n' +
|
'workshops-tagged events whose text also mentions trigonometry.\n\n' +
|
||||||
'Events that have already finished are left out — a name match on something that ' +
|
'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.',
|
'skip/take. A bare array.',
|
||||||
parameters: [
|
parameters: [
|
||||||
stringQuery('query', 'Search terms; `#tag` matches a tag, anything else the text'),
|
stringQuery('query', 'Search terms; `#tag` matches a tag, anything else the text'),
|
||||||
stringQuery(
|
stringQuery(
|
||||||
'sort',
|
'sort',
|
||||||
'Accepted and echoed by the client as `StartTime`, which is the only order ' +
|
'`StartTime` (soonest first, the default) or `Attendance` (most Going replies ' +
|
||||||
'served (soonest first); any other value sorts the same way'
|
'first — the event’s `AttendeeCount` — ties soonest first). Case-insensitive; ' +
|
||||||
|
'any other value is served as `StartTime`'
|
||||||
),
|
),
|
||||||
...pageParams(50),
|
...pageParams(50),
|
||||||
],
|
],
|
||||||
@@ -616,7 +618,15 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
|
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
|
||||||
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
|
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'))
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8677,6 +8677,71 @@ describe('player events', () => {
|
|||||||
expect(await search('?skip=1&take=1')).toEqual([all[1]])
|
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<PlayerEvent[]> =>
|
||||||
|
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
|
||||||
|
const respond = (id: number, type: number, sub: string): Promise<Response> =>
|
||||||
|
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 () => {
|
test('GET /api/playerevents/v1 serves the browse feed as listings', async () => {
|
||||||
const res = await get('/api/playerevents/v1')
|
const res = await get('/api/playerevents/v1')
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
|||||||
Reference in New Issue
Block a user