From ea2b413d2585fe5cbd7ae7673c1533f9168de91d Mon Sep 17 00:00:00 2001 From: Bit Nimble Date: Tue, 9 Jun 2026 00:49:27 +1000 Subject: [PATCH] feat: add total hits count --- .claude/bash-redirects.json | 3 +- src/app/api/fake_api.ts | 4 +- src/app/api/maps/route.ts | 2 +- src/app/map_list_presenter.ts | 7 ++- src/app/search.tsx | 6 +++ src/schema/maps.ts | 2 + src/services/maps/maps_repo.ts | 10 +++- src/services/maps/tests/maps_repo.test.ts | 2 +- .../maps/tests/maps_repo_filters.test.ts | 21 +++++++- src/services/search/postgres.ts | 52 ++++++++++--------- src/services/search/types.ts | 3 +- 11 files changed, 77 insertions(+), 35 deletions(-) diff --git a/.claude/bash-redirects.json b/.claude/bash-redirects.json index 22b0e0f..af6aaac 100644 --- a/.claude/bash-redirects.json +++ b/.claude/bash-redirects.json @@ -10,7 +10,8 @@ "Bash(bun tsc *)", "Bash(bun prettier *)", "Bash(bunx prettier *)", - "Bash(tsc *)" + "Bash(tsc *)", + "Bash(./node_modules/.bin/tsc *)" ], "reason": "Use bun lint, bun typecheck, or bun format instead of invoking tools directly." }, diff --git a/src/app/api/fake_api.ts b/src/app/api/fake_api.ts index 0892260..788ac96 100644 --- a/src/app/api/fake_api.ts +++ b/src/app/api/fake_api.ts @@ -46,11 +46,11 @@ export class FakeApi implements Api { async findMaps(): Promise { await delay(); - return { success: true, maps: fakeMaps }; + return { success: true, maps: fakeMaps, totalCount: fakeMaps.length }; } async searchMaps(_req: SearchMapsRequest): Promise { await delay(); - return { success: true, maps: fakeMaps }; + return { success: true, maps: fakeMaps, totalCount: fakeMaps.length }; } async getMap(id: string): Promise { await delay(1000); diff --git a/src/app/api/maps/route.ts b/src/app/api/maps/route.ts index b49e849..320abdf 100644 --- a/src/app/api/maps/route.ts +++ b/src/app/api/maps/route.ts @@ -59,5 +59,5 @@ export async function GET(req: NextRequest): Promise { errorMessage: 'Could not retrieve map: ' + joinErrors(result), }); } - return send({ success: true, maps: result.value }); + return send({ success: true, maps: result.value.maps, totalCount: result.value.totalCount }); } diff --git a/src/app/map_list_presenter.ts b/src/app/map_list_presenter.ts index f4ece08..388f59e 100644 --- a/src/app/map_list_presenter.ts +++ b/src/app/map_list_presenter.ts @@ -19,6 +19,7 @@ export class MapListStore { @observable accessor selectedMaps = new Map(); @observable accessor lastSelectedMapIndex: number | undefined = undefined; @observable accessor maps: PDMap[] | undefined = undefined; + @observable accessor totalCount: number | undefined = undefined; @observable accessor hasMore = true; @observable accessor loadingMore = false; @@ -151,7 +152,10 @@ export class MapListPresenter { } } const sort = this.getTableSortParams(); - runInAction(() => (this.store.maps = undefined)); + runInAction(() => { + this.store.maps = undefined; + this.store.totalCount = undefined; + }); const resp = await this.api.searchMaps({ query: this.store.query, limit: SEARCH_LIMIT, @@ -162,6 +166,7 @@ export class MapListPresenter { if (resp.success) { runInAction(() => { this.store.maps = resp.maps; + this.store.totalCount = resp.totalCount; this.store.hasMore = resp.maps.length >= SEARCH_LIMIT; }); } diff --git a/src/app/search.tsx b/src/app/search.tsx index b0a0615..6759011 100644 --- a/src/app/search.tsx +++ b/src/app/search.tsx @@ -10,6 +10,7 @@ import { encodeFilter } from 'schema/map_filter'; import { Button } from 'ui/base/button/button'; import { filterIcon } from 'ui/base/icons/filter_icon'; import { searchIcon } from 'ui/base/icons/search_icon'; +import { T } from 'ui/base/text/text'; import { Textbox } from 'ui/base/textbox/textbox'; import styles from './search.module.css'; @@ -83,6 +84,11 @@ export const Search = observer((props: { store: MapListStore; presenter: MapList {!store.filtersExpanded && } {store.filtersExpanded && } + {store.totalCount != null && ( + + {store.totalCount} {store.totalCount === 1 ? 'map' : 'maps'} + + )} ); }); diff --git a/src/schema/maps.ts b/src/schema/maps.ts index bb6153a..8ae43b4 100644 --- a/src/schema/maps.ts +++ b/src/schema/maps.ts @@ -78,6 +78,8 @@ export type DeleteMapResponse = z.infer; /* GET findMaps */ export const FindMapsSuccess = ApiSuccess.extend({ maps: z.array(PDMap), + // Total number of maps matching the query, ignoring pagination. + totalCount: z.number(), }); export type FindMapsSuccess = z.infer; diff --git a/src/services/maps/maps_repo.ts b/src/services/maps/maps_repo.ts index dbfc242..5aab3b3 100644 --- a/src/services/maps/maps_repo.ts +++ b/src/services/maps/maps_repo.ts @@ -157,7 +157,7 @@ export class MapsRepo { offset: number; limit: number; filter?: FilterNode; - }): PromisedResult { + }): PromisedResult<{ maps: PDMap[]; totalCount: number }, DbError> { const { user, query, offset, limit, sort, sortDirection, filter } = searchOptions; const response = await this.searchIndex.search(query, { offset, @@ -176,7 +176,13 @@ export class MapsRepo { } const maps = new Map(mapsResult.value.map((m) => [m.id, m])); - return { success: true, value: searchResults.map((m) => maps.get(m.id)).filter(exists) }; + return { + success: true, + value: { + maps: searchResults.map((m) => maps.get(m.id)).filter(exists), + totalCount: response.totalCount, + }, + }; } async getMap(mapId: string, userId?: string): PromisedResult { diff --git a/src/services/maps/tests/maps_repo.test.ts b/src/services/maps/tests/maps_repo.test.ts index 311b2b1..00ad4ed 100644 --- a/src/services/maps/tests/maps_repo.test.ts +++ b/src/services/maps/tests/maps_repo.test.ts @@ -40,7 +40,7 @@ describe('maps repo', () => { limit: 5, }); expect(result.success).toBe(true); - const ids = (result as Extract).value.map((m) => m.id); + const ids = (result as Extract).value.maps.map((m) => m.id); expect(ids.includes('3')).toBe(false); }); diff --git a/src/services/maps/tests/maps_repo_filters.test.ts b/src/services/maps/tests/maps_repo_filters.test.ts index 8c7d6e6..8b6dce4 100644 --- a/src/services/maps/tests/maps_repo_filters.test.ts +++ b/src/services/maps/tests/maps_repo_filters.test.ts @@ -41,7 +41,7 @@ async function searchIds(filter: FilterNode, query = '') { if (!result.success) { throw new Error('searchMaps failed'); } - return result.value.map((m) => m.id).sort(); + return result.value.maps.map((m) => m.id).sort(); } describe('maps repo search filters', () => { @@ -162,6 +162,25 @@ describe('maps repo search filters', () => { expect(ids).toEqual(['200']); }); + it('reports the full match count regardless of the page limit', async () => { + for (const id of ['600', '601', '602']) { + await insertMap({ id, artist: 'CountTest' }); + } + const { mapsRepo } = await getServerContext(); + const result = await mapsRepo.searchMaps({ + query: '', + offset: 0, + limit: 2, + filter: { type: 'cmp', field: 'artist', op: 'contains', value: 'CountTest' }, + }); + if (!result.success) { + throw new Error('searchMaps failed'); + } + // The page is capped at the limit, but totalCount counts all matching maps. + expect(result.value.maps).toHaveLength(2); + expect(result.value.totalCount).toBe(3); + }); + describe('LIKE-wildcard escaping', () => { it('treats % in a contains value literally', async () => { await insertMap({ id: '300', description: '100% complete' }); diff --git a/src/services/search/postgres.ts b/src/services/search/postgres.ts index a4efd4c..875aae2 100644 --- a/src/services/search/postgres.ts +++ b/src/services/search/postgres.ts @@ -48,15 +48,15 @@ export class PostgresIndex implements SearchIndex { } let results; + let totalCount: number; const queryMostRecent = () => { - return db - .select( - 'maps', - db.conditions.and( - { visibility: MapVisibility.PUBLIC }, - ...(filter ? [compileFilter(filter)] : []) - ), - { + const conditions = db.conditions.and( + { visibility: MapVisibility.PUBLIC }, + ...(filter ? [compileFilter(filter)] : []) + ); + return Promise.all([ + db + .select('maps', conditions, { columns: ['id'], lateral: sortLateral, order: sortOrder ?? { @@ -65,20 +65,21 @@ export class PostgresIndex implements SearchIndex { }, limit, offset, - } - ) - .run(pool); + }) + .run(pool), + db.count('maps', conditions).run(pool), + ]); }; if (query.trim() === '') { - results = await queryMostRecent(); + [results, totalCount] = await queryMostRecent(); } else { const [{ tsquery }] = await db.sql< db.Parameter, [{ tsquery: string }] >`select websearch_to_tsquery('english', ${db.param(query)})::text as tsquery`.run(pool); if (tsquery.trim() === '') { - results = await queryMostRecent(); + [results, totalCount] = await queryMostRecent(); } else { const tsqueryPartial = db.sql`(${db.param(tsquery)} || ':*')::tsquery`; @@ -91,15 +92,14 @@ export class PostgresIndex implements SearchIndex { const ftsMatch = db.sql`${'fts'} @@ ${tsqueryPartial}`; const rank = db.sql`ts_rank_cd(${'fts'}, ${tsqueryPartial})`; - results = await db - .select( - 'maps', - db.conditions.and( - { visibility: MapVisibility.PUBLIC }, - db.sql`(${exactMatch} OR ${ftsMatch})`, - ...(filter ? [compileFilter(filter)] : []) - ), - { + const conditions = db.conditions.and( + { visibility: MapVisibility.PUBLIC }, + db.sql`(${exactMatch} OR ${ftsMatch})`, + ...(filter ? [compileFilter(filter)] : []) + ); + [results, totalCount] = await Promise.all([ + db + .select('maps', conditions, { columns: ['id'], lateral: sortLateral, order: sortOrder ?? [ @@ -112,14 +112,16 @@ export class PostgresIndex implements SearchIndex { rank, exactMatch, }, - } - ) - .run(pool); + }) + .run(pool), + db.count('maps', conditions).run(pool), + ]); } } return { hits: results.map((r) => ({ id: r.id })), + totalCount, }; } diff --git a/src/services/search/types.ts b/src/services/search/types.ts index f31bbbc..2c1fddb 100644 --- a/src/services/search/types.ts +++ b/src/services/search/types.ts @@ -4,9 +4,10 @@ import { MapSortableAttributes, PDMap } from 'schema/maps'; // Input type for indexing maps - accepts PDMap or partial with required id export type MapDocument = Partial & { id: string }; -// Search result - returns matching IDs +// Search result - returns matching IDs along with the total count of matches ignoring pagination export type SearchResult = { hits: Array<{ id: string }>; + totalCount: number; }; export type SortOption = {