Skip to content

Commit e62579b

Browse files
authored
feat: add total hits count (#73)
1 parent c02e3f8 commit e62579b

11 files changed

Lines changed: 77 additions & 35 deletions

File tree

.claude/bash-redirects.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"Bash(bun tsc *)",
1111
"Bash(bun prettier *)",
1212
"Bash(bunx prettier *)",
13-
"Bash(tsc *)"
13+
"Bash(tsc *)",
14+
"Bash(./node_modules/.bin/tsc *)"
1415
],
1516
"reason": "Use bun lint, bun typecheck, or bun format instead of invoking tools directly."
1617
},

src/app/api/fake_api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ export class FakeApi implements Api {
4646

4747
async findMaps(): Promise<FindMapsResponse> {
4848
await delay();
49-
return { success: true, maps: fakeMaps };
49+
return { success: true, maps: fakeMaps, totalCount: fakeMaps.length };
5050
}
5151
async searchMaps(_req: SearchMapsRequest): Promise<FindMapsResponse> {
5252
await delay();
53-
return { success: true, maps: fakeMaps };
53+
return { success: true, maps: fakeMaps, totalCount: fakeMaps.length };
5454
}
5555
async getMap(id: string): Promise<GetMapResponse> {
5656
await delay(1000);

src/app/api/maps/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,5 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
5959
errorMessage: 'Could not retrieve map: ' + joinErrors(result),
6060
});
6161
}
62-
return send({ success: true, maps: result.value });
62+
return send({ success: true, maps: result.value.maps, totalCount: result.value.totalCount });
6363
}

src/app/map_list_presenter.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export class MapListStore {
1919
@observable accessor selectedMaps = new Map<string, true>();
2020
@observable accessor lastSelectedMapIndex: number | undefined = undefined;
2121
@observable accessor maps: PDMap[] | undefined = undefined;
22+
@observable accessor totalCount: number | undefined = undefined;
2223
@observable accessor hasMore = true;
2324
@observable accessor loadingMore = false;
2425

@@ -151,7 +152,10 @@ export class MapListPresenter {
151152
}
152153
}
153154
const sort = this.getTableSortParams();
154-
runInAction(() => (this.store.maps = undefined));
155+
runInAction(() => {
156+
this.store.maps = undefined;
157+
this.store.totalCount = undefined;
158+
});
155159
const resp = await this.api.searchMaps({
156160
query: this.store.query,
157161
limit: SEARCH_LIMIT,
@@ -162,6 +166,7 @@ export class MapListPresenter {
162166
if (resp.success) {
163167
runInAction(() => {
164168
this.store.maps = resp.maps;
169+
this.store.totalCount = resp.totalCount;
165170
this.store.hasMore = resp.maps.length >= SEARCH_LIMIT;
166171
});
167172
}

src/app/search.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { encodeFilter } from 'schema/map_filter';
1010
import { Button } from 'ui/base/button/button';
1111
import { filterIcon } from 'ui/base/icons/filter_icon';
1212
import { searchIcon } from 'ui/base/icons/search_icon';
13+
import { T } from 'ui/base/text/text';
1314
import { Textbox } from 'ui/base/textbox/textbox';
1415
import styles from './search.module.css';
1516

@@ -83,6 +84,11 @@ export const Search = observer((props: { store: MapListStore; presenter: MapList
8384
</div>
8485
{!store.filtersExpanded && <ActiveFilterPills store={store} onSearch={onSearch} />}
8586
{store.filtersExpanded && <FilterBuilder store={store} onSearch={onSearch} />}
87+
{store.totalCount != null && (
88+
<T.Tiny color="grey" display="block">
89+
{store.totalCount} {store.totalCount === 1 ? 'map' : 'maps'}
90+
</T.Tiny>
91+
)}
8692
</div>
8793
);
8894
});

src/schema/maps.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export type DeleteMapResponse = z.infer<typeof DeleteMapResponse>;
7878
/* GET findMaps */
7979
export const FindMapsSuccess = ApiSuccess.extend({
8080
maps: z.array(PDMap),
81+
// Total number of maps matching the query, ignoring pagination.
82+
totalCount: z.number(),
8183
});
8284
export type FindMapsSuccess = z.infer<typeof FindMapsSuccess>;
8385

src/services/maps/maps_repo.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export class MapsRepo {
157157
offset: number;
158158
limit: number;
159159
filter?: FilterNode;
160-
}): PromisedResult<PDMap[], DbError> {
160+
}): PromisedResult<{ maps: PDMap[]; totalCount: number }, DbError> {
161161
const { user, query, offset, limit, sort, sortDirection, filter } = searchOptions;
162162
const response = await this.searchIndex.search(query, {
163163
offset,
@@ -176,7 +176,13 @@ export class MapsRepo {
176176
}
177177

178178
const maps = new Map(mapsResult.value.map((m) => [m.id, m]));
179-
return { success: true, value: searchResults.map((m) => maps.get(m.id)).filter(exists) };
179+
return {
180+
success: true,
181+
value: {
182+
maps: searchResults.map((m) => maps.get(m.id)).filter(exists),
183+
totalCount: response.totalCount,
184+
},
185+
};
180186
}
181187

182188
async getMap(mapId: string, userId?: string): PromisedResult<PDMap, GetMapError> {

src/services/maps/tests/maps_repo.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('maps repo', () => {
4040
limit: 5,
4141
});
4242
expect(result.success).toBe(true);
43-
const ids = (result as Extract<typeof result, { success: true }>).value.map((m) => m.id);
43+
const ids = (result as Extract<typeof result, { success: true }>).value.maps.map((m) => m.id);
4444
expect(ids.includes('3')).toBe(false);
4545
});
4646

src/services/maps/tests/maps_repo_filters.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ async function searchIds(filter: FilterNode, query = '') {
4141
if (!result.success) {
4242
throw new Error('searchMaps failed');
4343
}
44-
return result.value.map((m) => m.id).sort();
44+
return result.value.maps.map((m) => m.id).sort();
4545
}
4646

4747
describe('maps repo search filters', () => {
@@ -192,6 +192,25 @@ describe('maps repo search filters', () => {
192192
expect(ids).toEqual(['200']);
193193
});
194194

195+
it('reports the full match count regardless of the page limit', async () => {
196+
for (const id of ['600', '601', '602']) {
197+
await insertMap({ id, artist: 'CountTest' });
198+
}
199+
const { mapsRepo } = await getServerContext();
200+
const result = await mapsRepo.searchMaps({
201+
query: '',
202+
offset: 0,
203+
limit: 2,
204+
filter: { type: 'cmp', field: 'artist', op: 'contains', value: 'CountTest' },
205+
});
206+
if (!result.success) {
207+
throw new Error('searchMaps failed');
208+
}
209+
// The page is capped at the limit, but totalCount counts all matching maps.
210+
expect(result.value.maps).toHaveLength(2);
211+
expect(result.value.totalCount).toBe(3);
212+
});
213+
195214
describe('LIKE-wildcard escaping', () => {
196215
it('treats % in a contains value literally', async () => {
197216
await insertMap({ id: '300', description: '100% complete' });

src/services/search/postgres.ts

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,15 @@ export class PostgresIndex implements SearchIndex {
4848
}
4949

5050
let results;
51+
let totalCount: number;
5152
const queryMostRecent = () => {
52-
return db
53-
.select(
54-
'maps',
55-
db.conditions.and(
56-
{ visibility: MapVisibility.PUBLIC },
57-
...(filter ? [compileFilter(filter)] : [])
58-
),
59-
{
53+
const conditions = db.conditions.and(
54+
{ visibility: MapVisibility.PUBLIC },
55+
...(filter ? [compileFilter(filter)] : [])
56+
);
57+
return Promise.all([
58+
db
59+
.select('maps', conditions, {
6060
columns: ['id'],
6161
lateral: sortLateral,
6262
order: sortOrder ?? {
@@ -65,20 +65,21 @@ export class PostgresIndex implements SearchIndex {
6565
},
6666
limit,
6767
offset,
68-
}
69-
)
70-
.run(pool);
68+
})
69+
.run(pool),
70+
db.count('maps', conditions).run(pool),
71+
]);
7172
};
7273

7374
if (query.trim() === '') {
74-
results = await queryMostRecent();
75+
[results, totalCount] = await queryMostRecent();
7576
} else {
7677
const [{ tsquery }] = await db.sql<
7778
db.Parameter,
7879
[{ tsquery: string }]
7980
>`select websearch_to_tsquery('english', ${db.param(query)})::text as tsquery`.run(pool);
8081
if (tsquery.trim() === '') {
81-
results = await queryMostRecent();
82+
[results, totalCount] = await queryMostRecent();
8283
} else {
8384
const tsqueryPartial = db.sql<maps.SQL, string>`(${db.param(tsquery)} || ':*')::tsquery`;
8485

@@ -91,15 +92,14 @@ export class PostgresIndex implements SearchIndex {
9192
const ftsMatch = db.sql<maps.SQL, boolean>`${'fts'} @@ ${tsqueryPartial}`;
9293
const rank = db.sql<maps.SQL, number>`ts_rank_cd(${'fts'}, ${tsqueryPartial})`;
9394

94-
results = await db
95-
.select(
96-
'maps',
97-
db.conditions.and(
98-
{ visibility: MapVisibility.PUBLIC },
99-
db.sql`(${exactMatch} OR ${ftsMatch})`,
100-
...(filter ? [compileFilter(filter)] : [])
101-
),
102-
{
95+
const conditions = db.conditions.and(
96+
{ visibility: MapVisibility.PUBLIC },
97+
db.sql`(${exactMatch} OR ${ftsMatch})`,
98+
...(filter ? [compileFilter(filter)] : [])
99+
);
100+
[results, totalCount] = await Promise.all([
101+
db
102+
.select('maps', conditions, {
103103
columns: ['id'],
104104
lateral: sortLateral,
105105
order: sortOrder ?? [
@@ -112,14 +112,16 @@ export class PostgresIndex implements SearchIndex {
112112
rank,
113113
exactMatch,
114114
},
115-
}
116-
)
117-
.run(pool);
115+
})
116+
.run(pool),
117+
db.count('maps', conditions).run(pool),
118+
]);
118119
}
119120
}
120121

121122
return {
122123
hits: results.map((r) => ({ id: r.id })),
124+
totalCount,
123125
};
124126
}
125127

0 commit comments

Comments
 (0)